
Когда iOS‑приложение вырастает до сотен тысяч строк, появляется проблема: добавление зависимости в глубокий компонент требует изменений во всех промежуточных функциях. Эти функции зависимость не используют — они просто передают её дальше. Сигнатуры разбухают, рефакторинг превращается в массовую правку файлов, и значительная часть кода становится техническим шумом.
Проблема известна. Scala использует implicit parameters на уровне языка, Kotlin экспериментирует с context receivers, Android полагается на Dagger. А Swift не предлагает встроенного решения. Поэтому мы в команде Яндекс Браузера создали библиотеку Implicits — механизм неявной передачи зависимостей с compile‑time‑проверками. Она успешно работает в продакшне Браузера на полутора миллионах строк Swift‑кода, а ещё доступна в опенсорсе.
В этой статье я расскажу о поиске собственного подхода для передачи зависимостей в коде на Swift, о том, как внедрение Implicits позволяет существенно сократить boilerplate, ускорить рефакторинг и улучшить читаемость кода благодаря локальному объявлению только реально используемых зависимостей, а также покажу реальные примеры из продакшн‑кода мобильной версии Яндекс Браузера.
Проблема современных приложений на iOS
Современные приложения страдают от проблемы, при которой значительная часть кода служит не для реализации функциональности, а для транзита параметров через промежуточные слои. Добавление зависимости в компонент требует модификации всего пути от корня до этого компонента в графе вызовов, причём промежуточные узлы транслируют параметр механически, не используя его. Объём необходимых изменений растёт пропорционально глубине иерархии, и в крупных проектах одно логическое изменение порождает десятки технических правок в несвязанных модулях.
Вот как это выглядит в коде:
class ScreenBuilder {
func build(..., analytics: Analytics, ...) {
...
let container = buildContainer(..., analytics: analytics, ...)
...
}
func buildContainer(..., analytics: Analytics, ...) {
...
let list = buildList(..., analytics: analytics, ...)
...
}
func buildList(..., analytics: Analytics, ...) {
...
let cell = buildCell(..., analytics: analytics, ...)
...
}
func buildCell(..., analytics: Analytics) {
analytics.trackCellCreated() // Наконец-то используем!
}
}
Параметр analytics проходит через три промежуточных слоя, прежде чем достигнуть места, где он действительно нужен. На практике в крупных проектах таких слоёв может быть значительно больше.
А теперь представим, что нам нужно добавить ещё один параметр — logger:
class ScreenBuilder {
- func build(..., analytics: Analytics, ...) {
+ func build(
+ ...,
+ analytics: Analytics,
+ logger: Logger,
+ ...
+ ) {
...
- let container = buildContainer(..., analytics: analytics, ...)
+ let container = buildContainer(
+ ...,
+ analytics: analytics,
+ logger: logger,
+ ...
+ )
...
}
- func buildContainer(..., analytics: Analytics, ...) {
+ func buildContainer(
+ ...,
+ analytics: Analytics,
+ logger: Logger,
+ ...
+ ) {
...
- let list = buildList(..., analytics: analytics, ...)
+ let list = buildList(
+ ...,
+ analytics: analytics,
+ logger: logger,
+ ...
+ )
...
}
Видно, как добавление одного параметра размножается по всей цепочке: каждый промежуточный метод приходится обновлять, хотя logger нужен только в конце.
Отсюда возникают негативные эффекты:
Код теряет семантическое значение. Вместо того чтобы видеть бизнес‑логику и архитектурные решения, мы видим в основном техническую передачу параметров, и возникает парадокс: код получается явным, но при этом менее понятным — весь путь передачи параметров на виду, но технические детали заслоняют смысл.
Большой объём boilerplate. В крупных проектах до 20% кода может составлять прокидывание параметров, при этом сигнатуры функций разбухают до 10–15 параметров, и файлы растут не за счёт логики, а за счёт транзита зависимостей.
Сложность рефакторинга. Добавление одного параметра требует изменения десятков файлов. И хотя компилятор укажет все места, где необходимы правки, объём механической работы всё равно остаётся огромным — это утомительная рутина, которая отнимает время и силы.
Нарушение инкапсуляции. Промежуточные слои оказываются вынуждены знать о зависимостях, которые им не нужны. Размываются границы ответственности, и усложняется понимание того, что действительно требуется каждому модулю, тогда как желательно, чтобы модуль зависел только от того, что он непосредственно использует.
Эти проблемы усугубляются с ростом проекта — чем больше компонентов и глубже граф зависимостей, тем болезненнее каждое изменение. Грубо говоря, чтобы прокинуть зависимость из корня в листовой компонент, требуется прокидываний, где
— количество компонентов в системе (глубина дерева компонентов обычно растёт логарифмически).
Хотя логарифмический рост не выглядит пугающим, в проектах на тысячи компонентов это превращается в существенную константу. Если в небольшом проекте на 10 тысяч строк это ещё терпимо, то в крупных проектах на сотни тысяч строк это становится одной из основных болей разработки.
Рассмотрим наш Яндекс Браузер: он включает несколько сотен Swift‑модулей и около полутора миллионов строк кода. Примерно 400 тысяч из них «пустые» (комментарии, пустые строки, скобки), а из оставшегося миллиона половина приходится на код, связанный с передачей параметров: под этим мы понимаем аргументы функций и константные хранимые свойства.
Эвристический анализ показывает, что из 500 тысяч строк около 200 тысяч — чистое прокидывание: получили параметр и передали его дальше, не используя. Например, в корневых фабриках нашего проекта прокидывание параметров занимает около трети кода.
Получается, что примерно 20% кода — это техническое прокидывание параметров, которое не несёт смысловой нагрузки. Сокращение этого объёма заметно улучшит качество кодовой базы за счёт лучшей читаемости и меньшего контекста при изменениях, что приведёт к более быстрой разработке. И в конечном счёте всё это позволит быстрее доставлять фичи пользователям.
Существующие практики в iOS
Проблема прокидывания параметров известна iOS‑сообществу давно, и за годы сформировалось несколько подходов к её решению. Каждый из них предлагает свой набор компромиссов между удобством, безопасностью и контролем. Рассмотрим основные практики и их характеристики.
Синглтоны
Наиболее распространённый подход — сделать зависимости глобально доступными через синглтоны:
class Analytics {
static let shared = Analytics()
func trackEvent(_ name: String) {
// ...
}
}
class ScreenBuilder {
static let shared = ScreenBuilder()
func buildCell() {
Analytics.shared.trackEvent("cell_created")
Logger.shared.log("Cell built")
}
}
Синглтоны сокращают объём кода: не нужно прокидывать параметры и объявлять зависимости, обращение к Analytics.shared можно сделать в любом месте приложения.
Использование синглтонов быстро распространяется по всей кодовой базе: если ScreenBuilder — синглтон, то Analytics и Logger, от которых он зависит, тоже обязаны быть синглтонами, потому что в статическом контексте создания ScreenBuilder.shared невозможно прокинуть зависимости извне. В результате весь граф зависимостей превращается в граф синглтонов, где каждый класс обращается к другим через .shared, создавая плотную сеть глобальных связей.
Первая очевидная проблема — сложность тестирования. Подменить глобальный объект в одном тесте, не затронув другие, становится нетривиальной задачей, особенно при параллельном выполнении тестов. Частично это решается через convenience‑инициализаторы: один, без параметров для production‑кода, использует синглтон, другой принимает все зависимости явно для тестов:
class ScreenBuilder {
static let shared = ScreenBuilder()
private let analytics: Analytics
private let logger: Logger
convenience init() {
self.init(
analytics: Analytics.shared,
logger: Logger.shared
)
}
init(analytics: Analytics, logger: Logger) {
self.analytics = analytics
self.logger = logger
}
}
// В тестах:
let builder = ScreenBuilder(
analytics: MockAnalytics(),
logger: MockLogger()
)
Видно, как простая система начинает обрастать поддерживающим кодом: два инициализатора, явное объявление зависимостей, моки для тестов.
При тестировании нескольких классов вместе проблема усугубляется. Например, введём ScreenAnalytics, который оборачивает общую аналитику:
class ScreenAnalytics {
static let shared = ScreenAnalytics()
private let analytics: Analytics
convenience init() {
self.init(analytics: Analytics.shared)
}
init(analytics: Analytics) {
self.analytics = analytics
}
}
class ScreenBuilder {
static let shared = ScreenBuilder()
private let screenAnalytics: ScreenAnalytics
private let logger: Logger
convenience init() {
self.init(
screenAnalytics: ScreenAnalytics.shared,
logger: Logger.shared
)
}
init(screenAnalytics: ScreenAnalytics, logger: Logger) {
self.screenAnalytics = screenAnalytics
self.logger = logger
}
}
// В тестах нужно создавать весь граф вручную:
let analytics = MockAnalytics()
let screenAnalytics = ScreenAnalytics(analytics: analytics)
let builder = ScreenBuilder(
screenAnalytics: screenAnalytics,
logger: MockLogger()
)
Видим сразу несколько проблем. Во‑первых, код становится запутанным: сложно понять, где используются синглтоны, где есть явные зависимости. Во‑вторых, к этому добавляется глобальное изменяемое состояние — все синглтоны живут в глобальном пространстве (проблемы глобального стейта оставим за рамками этой статьи). В итоге получаем сложный и объёмный код плюс все проблемы синглтонов.
Вторая фундаментальная проблема — единственность экземпляра. Синглтон предполагает, что в приложении существует ровно один экземпляр сервиса. Однако есть множество случаев, когда это не работает.
Явный пример — режим инкогнито в Яндекс Браузере. Этот режим, по сути, открывает второй браузер с отдельными настройками: аналитика и логирование там сконфигурированы совсем иначе, чем в обычном браузере. Синглтон по определению один на всё приложение, а не на подсистему, что делает его непригодным для таких сценариев.
Есть также ряд менее существенных проблем, которые усложняют разработку. Среди них отсутствие инструментов для анализа графа зависимостей: нет способа понять, какой код что использует в своей глубине, какие зависимости он создаёт и с какими работает. А ещё все синглтоны живут всё время работы приложения, что не всегда желательно и усложняет управление жизненным циклом.
SwiftUI Environment
SwiftUI предлагает механизм Environment для передачи данных вниз по иерархии View без явного прокидывания через параметры:
struct RootView: View {
var body: some View {
ContentView()
.environment(\.analytics, AnalyticsService())
.environment(\.logger, Logger())
}
}
struct SomeNestedView: View {
@Environment(\.analytics) var analytics
@Environment(\.logger) var logger
var body: some View {
Button("Action") {
logger.log("Button tapped")
analytics.trackEvent("button_tap")
}
}
}
Механизм похож на синглтоны, но с возможностью переопределения: можно задать значения на верхнем уровне и получить их в любом дочернем View. Для SwiftUI это работает хорошо и решает проблему прокидывания параметров в UI‑слое. Но и тут есть свои проблемы.
Первая проблема — Environment работает только в иерархии SwiftUI View. Бизнес‑логика, сетевые сервисы, репозитории данных остаются без доступа к этому механизму.
Вторая существенная проблема — невозможность понять, какие значения из Environment использует View. Нет способа посмотреть граф зависимостей, нет инструментов для анализа. Даже Apple в документации не указывает, какие Environment values влияют на конкретные View. Иногда приходится методом проб определять, какое значение влияет на поведение компонента.
При частом использовании Environment требуется сложный механизм контроля переопределения значений. Без такого контроля легко случайно переопределить значение где‑то в середине иерархии, и проблема проявится только в runtime.
Environment — лучшая альтернатива синглтонам для UI‑слоя, но остальная кодовая база остаётся с необходимостью прокидывать параметры вручную.
DI-контейнеры
Существует несколько распространённых DI‑фреймворков, автоматизирующих управление зависимостями: Dependencies от Point‑Free, Swinject и Needle. Рассмотрим их по отдельности.
Dependencies
Dependencies от Point‑Free, по сути, расширяет концепцию SwiftUI Environment на весь код приложения. Библиотека использует property wrappers для декларативного объявления зависимостей, позволяя получать их в любом месте без явного прокидывания:
final class ScreenBuilder {
@Dependency(\.analytics) var analytics
@Dependency(\.logger) var logger
func buildCell() {
analytics.trackEvent("cell_created")
logger.log("Cell built")
}
}
Библиотека особенно хорошо работает с Composable Architecture от Point‑Free, но может использоваться и в другом коде. Механизм похож на Environment: можно задать зависимости на верхнем уровне и получить их в любом месте графа.
А проблемы наследуются от Environment и синглтонов. Граф зависимостей остаётся невидимым, что усложняет понимание кода. Также библиотека требует дефолтные значения для всех зависимостей, и если забыть переопределить значение там, где это необходимо, приложение продолжит работу с дефолтом — проблема проявится в runtime, а может быть, и в production.
По сути, это те же синглтоны, но с более простым механизмом переопределения.
Swinject
Swinject предлагает централизованную регистрацию зависимостей и автоматическое разрешение графа:
let container = Container()
container.register(Analytics.self) { _ in AnalyticsService() }
container.register(Logger.self) { _ in ConsoleLogger() }
container.register(ScreenBuilder.self) { resolver in
ScreenBuilder(
analytics: resolver.resolve(Analytics.self)!,
logger: resolver.resolve(Logger.self)!
)
}
let builder = container.resolve(ScreenBuilder.self)!
Swinject сокращает объём boilerplate‑кода: вместо ручного прокидывания параметров через десятки слоёв достаточно описать граф зависимостей один раз.
Основная проблема — потеря compile‑time‑проверок. Разрешение зависимостей происходит в runtime: если забыть зарегистрировать зависимость или допустить циклическую зависимость, приложение упадёт в production. Force unwrap (resolve(Analytics.self)!) в примере выше — типичный код при работе с Swinject.
Также Swinject добавляет заметный runtime overhead: резолвинг графа зависимостей при старте приложения может занимать сотни миллисекунд.
Needle
Needle решает проблему runtime‑проверок из Swinject через кодогенерацию на этапе сборки, обеспечивая compile‑time‑безопасность. Библиотека предполагает описание графа зависимостей через Component‑классы и Dependency protocols:
protocol ScreenBuilderDependency: Dependency {
var analytics: Analytics { get }
var logger: Logger { get }
}
class ScreenBuilderComponent: Component<ScreenBuilderDependency> {
func buildCell() -> UITableViewCell {
dependency.analytics.trackEvent("cell_created")
dependency.logger.log("Cell built")
return Cell()
}
}
Ключевая проблема Needle — отсутствие агностичности к архитектуре проекта. Библиотека работает только с определённой структурой: зависимости в protocols, компоненты как наследники Component, граф как иерархическое дерево с явным parent. Если архитектура проекта другая, код нужно переписывать под модель Needle.
Эта проблема усугубляется тем, что Needle распространяется по всему дереву зависимостей: начав использовать в одном месте, приходится адаптировать родительский Component, затем его родителя, и так до корня. Для существующего проекта это означает переписывание всего графа зависимостей, что делает постепенное внедрение практически невозможным. В больших проектах появляется проблема производительности сборки: Needle генерирует единый файл со всем графом зависимостей, и при любом изменении генератор парсит все Swift‑файлы проекта и перегенерирует этот файл, который может содержать десятки тысяч строк.
Также, несмотря на compile‑time‑проверки в пользовательском коде, внутри Needle использует runtime lookup по строковым ключам:
registerProviderFactory("^->RootComponent->ScreenBuilderComponent", factory...)
При старте приложения все зависимости резолвятся через поиск по строковым путям, и в проектах с сотнями компонентов это множество lookup‑операций добавляет overhead при инициализации.
Идеи из других языков
Проблема прокидывания параметров не уникальна для Swift и iOS‑разработки — это известная сложность в разработке систем с крупными графами зависимостей и длинными цепочками прокидываний.
В других языках и экосистемах эту проблему пытаются решить уже довольно давно. Некоторые подходы можно назвать успешными, потому что они существуют долго и применяются в больших проектах. Рассмотрим наиболее близкие к нам и интересные из них.
Scala: Given/Using
Scala — один из первых языков, добавивших встроенную поддержку неявной передачи параметров на уровне языка. Механизм context parameters работает в Scala уже довольно давно, в том числе используется в крупных проектах вроде Apache Spark и Akka.
Вот как выглядит базовый пример:
given analytics: Analytics = AnalyticsService()
given logger: Logger = ConsoleLogger()
def buildCell()(using analytics: Analytics, logger: Logger): Cell = {
analytics.trackEvent("cell_created")
logger.log("Cell built")
Cell()
}
// Вызов без явной передачи параметров
val cell = buildCell()
Ключевое слово given указывает, что мы даём значение компилятору для использования в контексте, а using в сигнатуре функции показывает, что функция использует контекст. Компилятор видит, что buildCell() требует контекстные параметры типов Analytics и Logger, находит подходящие given‑значения в области видимости и подставляет их автоматически. Важно, что это происходит на этапе компиляции: если подходящего значения нет, код не скомпилируется.
Если функция не использует контекстные параметры напрямую, а только прокидывает их дальше, имена можно опускать, оставляя только типы:
def buildContainer()(using Analytics, Logger): Container = {
buildList() // Контекст передаётся автоматически
}
Механизм решает проблему прокидывания параметров частично: при добавлении нового параметра не нужно менять вызовы функций — компилятор передаёт контекстные параметры автоматически. Однако сигнатуры всех промежуточных функций всё равно приходится обновлять.
Вспомним пример из начала статьи, где мы добавляли параметр logger в ScreenBuilder и приходилось менять и сигнатуры, и все вызовы функций. В Scala тот же пример выглядел бы так:
class ScreenBuilder {
- def build()(...)(using Analytics): Screen = {
+ def build()(...)(using Analytics, Logger): Screen = {
...
val container = buildContainer(...) // Вызов не меняется!
...
}
- def buildContainer()(...)(using Analytics): Container = {
+ def buildContainer()(...)(using Analytics, Logger): Container = {
...
val list = buildList(...) // Вызов не меняется!
...
}
- def buildList()(...)(using Analytics): List = {
+ def buildList()(...)(using Analytics, Logger): List = {
...
val cell = buildCell(...) // Вызов не меняется!
...
}
- def buildCell()(...)(using analytics: Analytics): Cell = {
+ def buildCell()(...)(using analytics: Analytics, logger: Logger): Cell = {
analytics.trackEvent("cell_created")
+ logger.log("Cell built")
Cell()
}
}
Изменений становится меньше: вызовы не меняются, обновляются только объявления функций.
Из несомненных плюсов — compile‑time‑проверки и явная сигнатура функции, по которой видно, какие контекстные параметры она требует. Можно явно передать значение, переопределив given в локальной области видимости, и контекстные параметры естественно работают с композицией функций.
Но проблема решается только частично. Во‑первых, все промежуточные слои всё равно приходится править. Во‑вторых, ключом для поиска служит тип — мы не можем сделать контекстным параметром простой тип вроде строки или замыкания, их обязательно нужно оборачивать в новый тип, что не всегда удобно.
Kotlin: Context Parameters
Kotlin развивает ту же идею через механизм context parameters:
context(users: UserService)
fun outputMessage(message: String) {
users.log("Log: $message")
}
Механизм работает аналогично Scala: функция объявляет контекстные параметры, компилятор автоматически передаёт их по цепочке вызовов. Ограничения те же: при добавлении зависимости приходится обновлять все промежуточные слои, а ключом для поиска служит тип.
Механизм находится в beta‑статусе и пока не вышел в stable release. Команде Kotlin потребовалось несколько лет на разработку, и предыдущая экспериментальная версия (context receivers) была полностью переработана — это показывает нетривиальность проблемы.
Dagger
В JVM‑экосистеме доминирует другой подход — compile‑time‑кодогенерация через Dagger. Это DI‑фреймворк, который анализирует граф зависимостей на этапе компиляции и генерирует код для их разрешения без runtime‑рефлексии. Вот как выглядит код:
@Module
class AppModule {
@Provides
fun provideAnalytics(): Analytics = AnalyticsService()
@Provides
fun provideLogger(): Logger = ConsoleLogger()
}
@Component(modules = [AppModule::class])
interface AppComponent {
fun screenBuilder(): ScreenBuilder
}
class ScreenBuilder @Inject constructor(
private val analytics: Analytics,
private val logger: Logger
) {
fun build(): Screen = buildContainer()
private fun buildContainer(): Container = buildList()
private fun buildList(): List = buildCell()
private fun buildCell(): Cell {
analytics.trackEvent("cell_created")
logger.log("Cell built")
return Cell()
}
}
На этапе компиляции Dagger генерирует код, который строит полный граф зависимостей, проверяет его корректность и создаёт все необходимые фабрики.
Ключевое отличие от Scala и Kotlin — Dagger не устраняет прокидывание параметров напрямую. Вместо этого он автоматизирует создание всего графа: зависимости объявляются один раз в конструкторе, и фреймворк самостоятельно выстраивает граф.
Плюсы очевидны: валидация графа при компиляции, нулевые накладные расходы в рантайме, управление временем жизни объектов. Фреймворк используется в крупнейших Android‑приложениях и доказал свою масштабируемость.
Но есть заметные минусы:
Dagger известен крутой кривой обучения — разработчику нужно освоить десятки аннотаций, понять концепции компонентов, модулей и областей видимости, разобраться в правилах связывания зависимостей.
Когда что‑то идёт не так, приходится читать сгенерированный код, который может содержать тысячи строк.
Обработка аннотаций замедляет сборку в крупных проектах, особенно при инкрементальных пересборках.
Проблемы производительности Dagger в крупных проектах настолько серьёзны, что в Яндексе создали собственный фреймворк Yatagan — модифицированную версию Dagger, оптимизированную для быстрых сборок.
Философия нашего решения
Рассмотрев подходы из других языков, мы задумались о решении для Swift. С одной стороны, есть ручное прокидывание с его преимуществами. С другой — DI‑контейнеры, которые сокращают boilerplate, но имеют серьёзные недостатки.
Что, если попробовать взять плюсы от ручного прокидывания и сфокусироваться на уменьшении количества boilerplate?
Преимущества ручного прокидывания
Гибкость. Мы можем менять зависимость по пути её прокидывания. Можем создавать новую, передавать под протоколом, при необходимости использовать любые паттерны — lazy initialization для тяжёлых объектов, дополнительные слои абстракции для развязки модулей, даже можем размещать бизнес‑логику в фабриках, если это оправдано. То есть обращаться с этим как с обычным Swift‑кодом — вся гибкость языка нам доступна.
Выразительность. При ручном прокидывании мы можем легко и явно увидеть, как передаётся параметр из корня в листья графа зависимостей. А весь путь передачи записан текстом в коде, и по нему легко навигироваться с помощью IDE.
Время жизни. Зависимости живут по обычным правилам владения языка Swift. Можно обойтись без скоупов и контейнеров, которые часто усложняют работу в DI‑фреймворках.
Type Safety. Большой класс ошибок отлавливается на этапе компиляции. Многие DI‑фреймворки ищут и связывают зависимости в рантайме, поэтому не могут гарантировать compile‑time‑проверки.
Дополнительные требования, которые не менее важны
Универсальность. Механизм должен быть общего назначения — не конкретно для построения графа зависимостей в приложении, а для решения базовой проблемы прокидывания параметров через несколько функций без привязки к конкретному стилю или архитектуре.
Постепенное внедрение. Наш проект — это сотни тысяч строк кода на Swift. Мы не можем остановить разработку на недели, чтобы переписать всё под новый подход. Важно, чтобы механизм можно было использовать даже в легаси‑коде без предварительных изменений — внедрять постепенно, по частям.
Минимальный порог входа. Наша команда насчитывает десятки разработчиков. Нужно, чтобы механизм был простым для понимания и изучения — минимум концепций, понятный API, несколько базовых паттернов. Чтобы разработчик мог сразу начать работать и разбирался в деталях механизма по мере необходимости.
Производительность. Многие DI‑фреймворки резолвят зависимости в рантайме, тратя секунды при старте приложения. Механизм должен быть сопоставим по производительности с ручным прокидыванием параметров — никаких заметных задержек на запуске. Overhead должен быть практически нулевым, даже 100 миллисекунд — это неприемлемо много.
Инструментальная поддержка. Нам важно понимать, откуда пришла зависимость, проследить её путь, выяснить, какие зависимости доступны и откуда они взялись.
Компромиссы
Видно, что перечисленные выше требования частично противоречат друг другу. Например, выразительность противоречит лаконичности кода. Поэтому имеет смысл разделить их на три категории.
Ключевые свойства. Мы не готовы идти на компромиссы в вопросах, которые составляют саму суть решения.
Механизм должен кардинально сокращать объём кода для передачи параметров — без существенного уменьшения boilerplate вся затея теряет смысл. При этом все зависимости должны проверяться компилятором на этапе сборки, чтобы ошибки обнаруживались во время разработки, а не превращались в runtime‑сбои в production.
Производительность тоже критична — механизм должен работать с минимальным overhead, выполняя основную работу статически во время компиляции.
Направления развития. Есть требования, с которыми мы готовы смириться в текущем состоянии, ожидая улучшений в будущем.
Порог входа может быть временно выше, пока API проходят «притирку», документация развивается и формируются устоявшиеся паттерны использования. Инструментальная поддержка тоже может развиваться — способы понять, откуда пришла зависимость и как она движется через код, могут стать удобнее и нагляднее.
Inherent‑компромиссы. Наконец, есть компромиссы, на которые мы готовы идти осознанно, — они связаны с природой решения, которое радикально сокращает boilerplate при передаче параметров.
Механизм, скрывающий часть прокидывания, делает код неявным в определённых сценариях — часть информации о построении графа зависимостей становится скрытой, и это фундаментальный баланс между лаконичностью и выразительностью, который инструменты могут частично компенсировать, но полностью не устранят.
А также есть необходимость изучения нового подхода — механизм, которого нет в стандартной практике iOS‑разработки, потребует времени на освоение от новых участников команды. Это создаёт дополнительную когнитивную нагрузку, с которой мы готовы смириться ради решения проблемы прокидывания параметров.
Как оно работает
Вернёмся к изначальной проблеме: в примере параметр analytics приходится прокидывать через три промежуточных слоя, прежде чем он достигнет места, где действительно нужен. При этом каждая промежуточная функция объявляет его в параметрах только для того, чтобы передать дальше.
Представим на минуту, что никаких технических ограничений нет, — тогда хотелось бы, чтобы код, полностью очищенный от прокидывания параметров, выглядел так:
func buildScreen() {
let container = buildContainer()
// ...
}
func buildContainer() -> Container {
let list = buildList()
// ...
}
func buildList() -> List {
let cell = buildCell()
// ...
}
func buildCell() -> Cell {
implicit var analytics: Analytics
implicit var logger: Logger
analytics.trackEvent("cell_created")
logger.log("Cell built")
return Cell()
}
Никаких параметров в промежуточных функциях — они просто вызывают друг друга, а в конечной функции buildCell параметры объявляются с ключевым словом implicit.
В месте вызова код выглядел бы так:
implicit var analytics = AnalyticsService()
implicit var logger = ConsoleLogger()
let screen = buildScreen()
Ключевое слово implicit отмечает параметр, который неявно передаётся через стек вызовов, и это не глобальная переменная — компилятор сам выводит для функций дополнительные implicit‑параметры.
Например, вот функция с обычными параметрами:
func processRequest(userId: String, timeout: Int) {
implicit var database: Database
implicit var logger: Logger
logger.log("Processing request for user \(userId)")
let user = database.fetchUser(userId)
// ...
}
Компилятор видел бы эту функцию так:
processRequest(userId: String, timeout: Int)
│
├─ Explicit-параметры:
│ ├─ userId: String
│ └─ timeout: Int
│
└─ Implicit-параметры:
├─ database: Database
└─ logger: Logger
Теперь представим, что есть другая функция, которая вызывает processRequest:
func handleUserAction(action: String) {
processRequest(userId: "123", timeout: 30)
}
Компилятор видит, что processRequest требует implicit‑параметры database и logger, а также автоматически выводит их для handleUserAction:
handleUserAction(action: String)
│
├─ Explicit-параметры:
│ └─ action: String
│
└─ Implicit-параметры:
├─ database: Database
└─ logger: Logger
Компилятор автоматически выводит implicit‑параметры для всех функций в цепочке вызовов, благодаря чему можно объявить их один раз в корне и они станут доступны во всех вложенных вызовах без явного прокидывания.
Важные нюансы
Рассмотрим некоторые аспекты этой концепции подробнее. В самом простом виде она выглядит предельно просто, но есть детали, которые стоит учитывать.
Динамическая диспетчеризация. Когда вызов идёт через протокол, компилятор не знает заранее, какая конкретная реализация будет вызвана, и не может автоматически вывести implicit‑параметры. Например:
protocol DataProcessor {
func process()
}
class NetworkProcessor: DataProcessor {
func process() {
implicit var network: NetworkService
// Обработка через сеть
}
}
class CacheProcessor: DataProcessor {
func process() {
implicit var cache: CacheService
// Обработка через кеш
}
}
// Вызов через протокол
let processor: DataProcessor = getProcessor()
processor.process() // Какие implicit-параметры нужны функции?
При компиляции протокола его реализации недоступны компилятору — они могут находиться в других модулях, которые компилируются отдельно, или появиться позже, поэтому невозможно автоматически вывести, какие implicit‑параметры понадобятся.
Возможны два подхода: либо отказаться от поддержки implicit‑параметров при вызовах через протоколы, либо добавить механизм их явного объявления непосредственно в протоколе — хотя с учётом наследования и других особенностей языка такая система может оказаться довольно сложной.
Изоляция скоупов. Локальное определение implicit‑параметра не должно влиять на внешний контекст, и наоборот — внешний контекст не должен переопределять локальный параметр. Например:
func handleRequest() {
implicit var logger = Logger.appLogger
processFeature()
// Здесь logger остаётся Logger.appLogger
}
func processFeature() {
implicit var logger = Logger.featureLogger
doSomething() // Использует Logger.featureLogger
}
Если фича определяет свой собственный logger, родительский Logger.appLogger не должен его переопределять, а Logger.featureLogger не должен влиять на родительский контекст — всё должно работать так же, как с обычными параметрами функций.
Пометка функций. Хотелось бы как‑то помечать функции, использующие implicit‑параметры, чтобы не удивлять разработчиков, которые читают код, — например, через аннотацию перед функцией:
@withImplicitParameters
func processRequest(userId: String) {
implicit var logger: Logger
// ...
}
Практическая реализация
Добавление подобной функциональности в компилятор Swift — сложная задача. По примеру Kotlin, даже более ограниченный механизм требует значительного времени на обсуждение и реализацию, поэтому мы пока не рассматриваем такой вариант, хотя и не отвергаем его полностью. Вместо этого рассмотрим, как подобный механизм может быть реализован на существующих возможностях языка.
Ключевое слово implicit из примера выше может быть реализовано через property wrapper @Implicit:
// Объявление implicit-переменной
@Implicit var analytics = AnalyticsService()
// Использование implicit-переменной
@Implicit var analytics: Analytics
analytics.trackEvent("button_tapped")
Если происходит присваивание начального значения — это объявление implicit‑переменной. Если присваивания нет, а указан только тип — это использование implicit‑значения, которое было объявлено выше по стеку вызовов.
Введём следующую концепцию — ImplicitScope, маркер контекста, который функции передают явно. Если у функции есть параметр scope: ImplicitScope, значит, она работает с implicit‑параметрами — такая пометка улучшает понимание кода и является компромиссом между удобством и прозрачностью.
Явная передача ImplicitScope решает и проблему динамической диспетчеризации: параметр scope в сигнатуре маркирует функции, работающие с implicit‑параметрами. Благодаря этому маркеру статический анализатор может корректно обрабатывать вызовы через протоколы и предупреждать о потенциальных проблемах — подробнее об анализе поговорим ниже.
Промежуточные функции передают ImplicitScope дальше по цепочке, не зная о конкретных implicit‑параметрах, — они работают с абстракцией контекста:
func handleUserAction(action: String, _ scope: ImplicitScope) {
processRequest(userId: "123", timeout: 30, scope)
}
func processRequest(
userId: String,
timeout: Int,
_ scope: ImplicitScope
) {
fetchAndProcess(userId: userId, scope)
}
func fetchAndProcess(userId: String, _: ImplicitScope) {
@Implicit var database: Database
@Implicit var logger: Logger
logger.log("Processing request for user \(userId)")
let user = database.fetchUser(userId)
// ...
}
Функции handleUserAction и processRequest прокидывают скоуп дальше, не используя implicit‑параметры. Функция fetchAndProcess обращается к @Implicit‑переменным.
Консистентность контекста в стеке вызовов обеспечивается через создание скоупа и его явную передачу по цепочке вызовов.
В функции, с которой начинается использование implicit‑параметров, создаётся скоуп, объявляются зависимости, и этот скоуп передаётся дальше:
let scope = ImplicitScope()
defer { scope.end() }
@Implicit var database = DatabaseService()
@Implicit var logger = ConsoleLogger()
handleUserAction(action: "tap", scope)
Когда создаётся ImplicitScope, он отмечает начало области видимости, к которой привязываются все последующие объявления implicit‑параметров.
При передаче scope через функции он служит маркером — каждая функция в цепочке знает, что работает с implicit‑параметрами, обращение к @Implicit‑переменным извлекает значения из текущего скоупа, а вызов scope.end() в defer восстанавливает предыдущее состояние, гарантируя изоляцию и отсутствие утечек памяти.
Когда нужно добавить дополнительные implicit‑параметры для части вычислений, используется вложенный скоуп через nested():
func handleRequest(_ scope: ImplicitScope) {
let scope = scope.nested()
defer { scope.end() }
// Объявляем logger для этого скоупа
@Implicit var logger = Logger.appLogger
processFeature(scope)
}
func processFeature(_ scope: ImplicitScope) {
let scope = scope.nested()
defer { scope.end() } // Восстановит контекст к состоянию на момент входа в функцию
// Переопределяем logger для этого вложенного скоупа
@Implicit var logger = Logger.featureLogger
// doSomething увидит Logger.featureLogger
doSomething(scope)
// В конце функции logger восстановится к Logger.appLogger
}
Type Keys и Named Keys
По умолчанию ключом для хранения значений служит тип:
@Implicit var network: NetworkService
@Implicit var database: DatabaseService
Такой способ идентификации называется type keys: когда объявляется @Implicit var network: NetworkService, ключом становится тип NetworkService. Это подходит для зависимостей, которые обычно прокидываются в стиле networkService: NetworkService, где имя параметра просто повторяет тип, — это признак того, что тип определяет семантику, и в конкретном месте использования обычно нужен только один экземпляр этого типа.
Обратная ситуация возникает, когда типа недостаточно для идентификации. Тогда используются named keys: параметры прокидываются с семантическими именами вроде apiURL: URL или cdnURL: URL, где имя добавляет важную информацию о назначении, позволяя различать несколько значений одного типа с разным назначением в одном контексте.
Для таких случаев используются именованные ключи, которые объявляются в расширении ImplicitKeys:
extension ImplicitKeys {
static let apiBaseURL = Key<URL>()
static let cdnBaseURL = Key<URL>()
}
// Объявление
@Implicit(\.apiBaseURL) var apiURL = URL(string: "https://api.example.com")!
@Implicit(\.cdnBaseURL) var cdnURL = URL(string: "https://cdn.example.com")!
// Использование
@Implicit(\.apiBaseURL) var apiURL: URL
@Implicit(\.cdnBaseURL) var cdnURL: URL
Именованный ключ уточняет назначение — общий тип URL превращается в конкретные URL API или URL CDN, явно указывая, для чего используется каждый экземпляр.
Выбор между type keys и named keys определяется тем, достаточно ли типа для идентификации: type keys лаконичнее и подходят для зависимостей, где тип определяет назначение, а named keys используются для различения нескольких значений одного типа.
Build-time-анализатор
Runtime‑реализация не представляет принципиальных сложностей: property wrapper @Implicit, thread-local storage для значений с привязкой к скоупам, управление временем жизни.
Однако отсутствие статического анализатора создаёт критическую проблему: приложение упадёт в runtime при отсутствии нужного implicit‑параметра, то есть разработчик должен самостоятельно следить за наличием всех необходимых параметров. Человеческий фактор приводит к тому, что ошибки случаются нередко, и обнаружить их в production очень неприятно.
Именно этот аспект определяет значительную часть сложности системы — требуется надёжный статический анализатор, который отслеживает вызовы функций, замечает отсутствующие параметры и находит некорректное использование implicits.
Для этого разработан статический анализатор, который работает как часть процесса сборки и проверяет корректность использования implicit‑параметров на этапе компиляции. Анализатор использует Swift Syntax для парсинга кода, строит синтаксическое дерево всех файлов модуля, находит все объявления и использования @Implicit, затем строит граф вызовов, где объекты в коде становятся узлами графа, каждый узел хранит информацию о том, какие implicit‑параметры он предоставляет (provides) и какие требует (requires), после чего узлы соединяются рёбрами по вызовам.
Например, если функция buildCell использует @Implicit var analytics: Analytics, она требует analytics. А если в ней же объявлен @Implicit var logger = ConsoleLogger(), то она предоставляет logger для вложенных вызовов.
Граф строится для всего модуля, после чего анализатор обходит его от точек входа — публичных функций, которые вызываются извне, и от каждой точки входа вычисляет транзитивное замыкание требований: какие implicit‑параметры в итоге понадобятся этой функции с учётом всех вложенных вызовов. Если на каком‑то уровне требуемый параметр отсутствует, анализатор выдаёт ошибку компиляции с указанием места, где параметр использован, и места, где его не хватает.
Для работы с модульными проектами анализатор генерирует интерфейсные файлы для каждого модуля — компактное описание публичных функций и их требований к implicit‑параметрам. Эти файлы распространяются по графу зависимостей подобно тому, как компилятор Swift распространяет .swiftmodule‑файлы. И когда модуль A вызывает функцию из модуля B, анализатор читает интерфейс модуля B и проверяет, что все необходимые implicit‑параметры доступны в точке вызова.
Итог
Итак, мы изучили механизм работы implicits. Теперь посмотрим, насколько он соответствует требованиям и задачам, которые заявлялись ранее, удалось ли нам реализовать то, что хотели, и какие существуют векторы дальнейшего развития.
Явность и инструментальная поддержка. Объект ImplicitScope прокидывается явно и ограничивает область применения implicits, разделяя код на зоны с неявной и явной передачей параметров. Статический анализатор отслеживает требования функций к implicit‑параметрам, что позволяет инструментам генерировать подсказки и документацию.
Сокращение boilerplate. Достаточно передать один параметр scope, и все зависимости становятся доступны ниже по стеку вызовов, при этом промежуточные функции не требуют изменений при добавлении новых зависимостей.
Compile‑time‑проверки. Статический анализатор проверяет корректность и отлавливает ошибки на этапе сборки, помогая избежать runtime‑ошибок и сокращая цикл обнаружения проблем.
Универсальность. Описанный механизм в сущности упрощает передачу аргументов функции, что делает его полезным практически в любой архитектуре.
Постепенное внедрение. Механизм работает параллельно с явным прокидыванием параметров, добавление scope не требует переписывания кода, что позволяет использовать implicits наряду с классическим подходом, например в новом коде или отдельных модулях.
Минимальный порог входа. Всего две основные концепции — ImplicitScope и @Implicit — делают систему довольно простой для освоения.
Производительность. Механизм работает с минимальным runtime overhead, работа сводится к поиску по хешу в словаре для операций с хранилищем. В Яндекс Браузере 90% перцентиль на построение графа составляет около 5 мс.
До и после
Теория — это хорошо, но ценность механизма проще всего понять через конкретные примеры из реального проекта.
После внедрения Implicits в Яндекс Браузере мы проанализировали использование механизма и нашли показательные случаи, демонстрирующие разницу между классическим прокидыванием параметров и подходом с implicit‑параметрами.
Постепенное внедрение: RootUIGraph
Рассмотрим инициализатор RootUIGraph — фабрику для построения корневой UI‑иерархии браузера. Это сложный компонент, который собирает вместе десятки подсистем: навигацию, вкладки, омнибокс, голосового ассистента, синхронизацию, аналитику и многое другое. Сигнатура этой функции содержит 103 параметра и требует доступ к 148 зависимостям, доступным через Implicits, — всего 251 зависимость.
Вот как выглядит реальная сигнатура:
func initRootUIGraph(
cookieController: WebViewCookieController,
func initRootUIGraph(
cookieController: CookieController,
clientIdentifiers: Lazy<ClientIDOperation>,
startupDispatcher: AppStartupDispatcher,
startupTimeProfiler: StartupTimeProfiler,
locationProvider: LocationProvider,
locationServicesEnabled: @escaping () -> Bool,
adblockRuleManager: AdblockRuleManager,
launchOptions: [UIApplication.LaunchOptionsKey: Any]?,
// ... ещё 95 параметров ...
_ scope: ImplicitScope
) -> RootUIGraph {
// Implicit-зависимости объявляются там, где используются:
@Implicit var telemetry: Telemetry
@Implicit var analytics: AnalyticsComponents
@Implicit var authService: AuthService
// ... и ещё 145 implicit-зависимостей
}
Все 103 параметра передаются явно, как обычные аргументы функции, а 148 зависимостей доступны через @Implicit. Explicit‑ и implicit‑параметры мирно сосуществуют в одной функции.
Без Implicits такая функция требовала бы 251 параметр в сигнатуре, с Implicits — 104 параметра (103 explicit + scope).
Ключевые свойства механизма: возможность постепенного внедрения, explicit‑параметры остаются на месте, implicit‑параметры добавляются по мере необходимости, не нужно переписывать существующую архитектуру.
Экстремальный пример с 251 параметром впечатляет, но нетипичен — мы привели его, чтобы показать возможность постепенного внедрения в легаси‑код. Не нужно останавливать разработку, переписывать всё приложение под новую архитектуру или вводить новые паттерны. Можно добавлять поддержку Implicits инкрементально, и механизм работает параллельно с классическим прокидыванием параметров.
Элегантный случай: HomePageFactory
Другой показательный пример — инициализатор HomePageFactory, фабрики для построения домашней страницы браузера. Вот его полная сигнатура:
class HomePageFactory {
init(_ scope: ImplicitScope) {
// Инициализация фабрики
}
}
Всего один параметр — ImplicitScope. При этом функция работает с 80 зависимостями, доступными через @Implicit. Без Implicits сигнатура этой функции содержала бы 80 параметров — это совершенно нечитаемый код, где специфичные для компонента зависимости терялись бы среди общих сервисов. С Implicits сигнатура содержит только scope, а все зависимости объявляются внутри функции там, где они действительно используются.
Такие функции встречаются не только в корневых фабриках — в нашем проекте 220 функций используют пять и более implicit‑параметров, это стандартная практика для компонентов, которым нужен доступ к множеству подсистем приложения.
Реальная экономия: Telemetry
Рассмотрим конкретный пример зависимости, который показывает экономию от использования Implicits.
Telemetry — относительно новый компонент в Яндекс Браузере, который собрал в одном месте всю телеметрию: логирование, аналитику, перформанс‑метрики, сервис репортинга ошибок. Это типичная cross‑cutting‑зависимость, которая нужна практически везде в приложении, — любой экран логирует действия пользователя, трекает события, отправляет метрики.
Согласно анализу кодовой базы, Telemetry является requirement примерно в 200 функциях — они объявляют @Implicit var telemetry: Telemetry и используют телеметрию напрямую или транзитивно через вложенные вызовы. При этом параметр telemetry явно прокидывается через параметры функций примерно в 350 местах — это код, который ещё не перешёл на Implicits.
Проект находится в процессе перехода: часть кода уже использует механизм implicit‑параметров, часть всё ещё прокидывает зависимости явно, и экономия очевидна даже при таком неполном переходе.
Чтобы увидеть экономию наглядно, проследим реальную цепочку вызовов для зависимости Telemetry в модуле голосового ассистента. ImplicitScope создаётся на уровне корня приложения, где объявляются все общие зависимости вроде Telemetry, а затем scope передаётся вниз по графу вызовов. Мы рассмотрим участок этой цепочки внутри модуля голосового ассистента — от момента, когда модуль получает scope, до места, где Telemetry реально используется.
Цепочка распространения scope (5 уровней):
VoiceModule.init
↓ Передаёт scope
VoiceUIFactory.init
↓ Сохраняет implicit-контекст (об этом механизме позже)
VoiceUIFactory.makeVoiceUI()
↓ Создаёт scope и передаёт его
VoiceUIAdapter.init
↓ Принимает scope
VoiceWaveformUI.init
✓ @Implicit var telemetry: Telemetry ← финальное использование
Сравнение ДО и ПОСЛЕ:
Без Implicits каждая функция в цепочке должна была бы принимать telemetry явно:
// ДО
VoiceModule.init(..., telemetry: Telemetry, ...) {
uiFactory = VoiceUIFactory(..., telemetry: telemetry, ...).makeVoiceUI
}
VoiceUIFactory.init(..., telemetry: Telemetry, ...) {
self.telemetry = telemetry
}
VoiceUIFactory.makeVoiceUI() -> VoiceCommandUI {
VoiceUIAdapter(..., telemetry: self.telemetry, ...)
}
VoiceUIAdapter.init(..., telemetry: Telemetry, ...) {
self.telemetry = telemetry
}
VoiceWaveformUI.init(..., telemetry: Telemetry, ...)
Каждый из пяти уровней вынужден объявлять telemetry в параметрах и хранить его, хотя используется он только на последнем уровне.
С Implicits промежуточные функции просто передают scope, не зная о существовании Telemetry:
// ПОСЛЕ
VoiceModule.init(..., scope) {
uiFactory = VoiceUIFactory(..., scope).makeVoiceUI
}
VoiceUIFactory.init(..., _: ImplicitScope) {
// scope доступен через #implicits
}
VoiceUIFactory.makeVoiceUI() -> VoiceCommandUI {
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return VoiceUIAdapter(..., scope)
}
VoiceUIAdapter.init(..., _: ImplicitScope) {
// scope передаётся в подкомпоненты
}
VoiceWaveformUI.init(..., _: ImplicitScope) {
@Implicit var telemetry: Telemetry // ← объявляем только там, где нужен
}
Пять уровней вложенности, и ни один промежуточный не несёт балласта прокидывания telemetry — они работают с абстракцией scope, который содержит все зависимости верхнего уровня.
Что изменилось
Сигнатуры стали короче и понятнее. Функция с 15 параметрами, из которых 12 — транзитные зависимости, теперь имеет три специфичных параметра плюс scope. По сигнатуре сразу видно, что действительно важно для этой функции.
Промежуточные функции очистились. Функции, которые раньше служили транспортом для десятка зависимостей, теперь просто передают scope и фокусируются на своей прямой задаче — композиции компонентов или координации вызовов.
Зависимости стали локальными. Каждая функция объявляет свои зависимости там, где они используются, а не наследует их через длинную цепочку параметров. Это упрощает понимание: читая функцию, видишь её реальные требования, а не весь транзитивный граф зависимостей.
Рефакторинг упростился. Добавление новой зависимости больше не требует правки десятков промежуточных функций. Объявляем зависимость в корне, используем в листьях, промежуточные слои остаются без изменений. Код не стал менее явным в плохом смысле — наоборот, он стал яснее. Вместо того чтобы видеть транзит зависимостей через все слои, мы видим чистую структуру компонентов, а зависимости объявлены локально, там, где они действительно нужны.
Сложные сценарии
До этого мы рассматривали простой случай: функция вызывает функцию, передавая ImplicitScope, и зависимости доступны в стеке вызовов. Однако реальные приложения содержат более сложные паттерны прокидывания зависимостей — объекты с хранимыми свойствами, замыкания, отложенное выполнение, трансформацию зависимостей на промежуточных уровнях.
Рассмотрим, как Implicits справляется с этими сценариями.
Хранимые зависимости: @Implicit stored properties
Самый простой способ сохранить implicit‑зависимости в объекте — объявить их как stored properties с @Implicit:
class SearchController {
@Implicit var analytics: Analytics
@Implicit var network: NetworkService
init(_ scope: ImplicitScope) {}
func performSearch(query: String) {
analytics.trackSearch(query: query)
network.search(query: query) { /* ... */ }
}
}
При создании объекта свойства analytics и network автоматически инициализируются из implicit‑контекста, после чего ведут себя как обычные stored properties.
Важно: инициализатор с параметром scope: ImplicitScope обязательно должен быть объявлен явно, даже если его тело пустое, — автоматически сгенерированный компилятором инициализатор не подходит, и статический анализатор выдаст ошибку компиляции.
Отложенное использование: захват контекста через #implicits
Типичная ситуация: объект создаётся в инициализаторе, получая доступ к implicit‑зависимостям, но использовать эти зависимости нужно позже — в методах, которые вызываются через секунды или минуты после создания объекта.
Рассмотрим пример: компонент управления вкладками браузера создаётся при старте приложения, но создавать новые вкладки нужно позже, при действиях пользователя.
Без Implicits:
class TabManager {
private let analytics: Analytics
private let telemetry: Telemetry
private let database: DatabaseService
private let sessionManager: SessionManager
init(
analytics: Analytics,
telemetry: Telemetry,
database: DatabaseService,
sessionManager: SessionManager
) {
self.analytics = analytics
self.telemetry = telemetry
self.database = database
self.sessionManager = sessionManager
}
func createTab() -> Tab {
return Tab(
analytics: analytics,
telemetry: telemetry,
database: database,
sessionManager: sessionManager
)
}
}
Все зависимости, даже если они используются редко, приходится сохранять как stored properties, чтобы иметь к ним доступ в методах объекта.
С Implicits:
class TabManager {
private let implicits = #implicits
init(_ scope: ImplicitScope) {
// Зависимости доступны в инициализаторе
}
func createTab() -> Tab {
// Восстанавливаем контекст зависимостей
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return Tab(scope)
}
}
Макрос #implicits захватывает минимальное подмножество зависимостей из текущего implicit‑контекста — только те, которые реально используются в методах объекта.
Статический анализатор определяет, какие @Implicit‑переменные объявлены в коде, использующем эту корзину (например, в методе createTab), и #implicits захватывает только их, игнорируя остальные доступные зависимости.
Снимок сохраняется в stored property implicits, и позже, когда метод createTab вызывается, контекст восстанавливается через ImplicitScope(with: implicits), делая эти зависимости снова доступными через @Implicit.
Важно: библиотека также предоставляет API без макросов для тех, кто предпочитает их не использовать, — функциональность доступна через явные вызовы функций вместо #implicits.
Это работает не только для обычных методов, но и для lazy properties:
class HomePageComponent {
private let implicits = #implicits
init(_ scope: ImplicitScope) {}
lazy var feedWidget: FeedWidget = {
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return FeedWidget(scope)
}()
}
Lazy property создаётся при первом обращении, но имеет доступ к зависимостям, которые были доступны в момент создания HomePageComponent.
Отложенное использование: замыкания
Другой распространённый паттерн — создание замыкания, которое вызовется позже: колбэки, обработчики событий, фабрики для отложенного создания объектов. Рассмотрим компонент, который делает API‑запрос и должен обработать ответ через callback, при этом сам callback нужно передать дальше в подкомпоненты как зависимость.
Без Implicits:
class SearchComponent {
private let network: NetworkService
private let analytics: Analytics
init(network: NetworkService, analytics: Analytics) {
self.network = network
self.analytics = analytics
let handleResponse: (SearchResponse) -> Void = { [analytics] response in
analytics.trackSearchResults(count: response.items.count)
// обработка ответа
}
self.searchUI = SearchUI(
handleResponse: handleResponse,
analytics: analytics
)
}
}
Зависимости нужно захватывать в closure вручную, а затем нужно прокидывать и замыкание, и его зависимости дальше в подкомпоненты.
С Implicits:
class SearchComponent {
init(_ scope: ImplicitScope) {
let scope = scope.nested()
defer { scope.end() }
// Создаём обработчик, который сам становится implicit-зависимостью
let handleResponse: (SearchResponse) -> Void = { [implicits = #implicits] response in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
@Implicit var analytics: Analytics
analytics.trackSearchResults(count: response.items.count)
// Обработка ответа
}
// Делаем handleResponse доступным как implicit
@Implicit var responseHandler = handleResponse
// SearchUI получает доступ и к handleResponse, и к другим зависимостям через scope
self.searchUI = SearchUI(scope)
}
}
Макрос #implicits в capture list замыкания [implicits = #implicits] захватывает минимальное подмножество зависимостей — только те, которые используются внутри этого замыкания.
Статический анализатор видит, что внутри замыкания объявлена @Implicit var analytics: Analytics, и захватывает только Analytics, игнорируя остальные доступные в контексте зависимости.
Затем само замыкание регистрируется как implicit‑зависимость через @Implicit var responseHandler = handleResponse и становится доступно в подкомпонентах через @Implicit var responseHandler: (SearchResponse) -> Void.
Это позволяет передавать не только сервисы, но и функции‑обработчики как часть implicit‑контекста, избегая явного прокидывания колбэков через параметры.
Трансформация зависимостей
Иногда на промежуточном уровне нужно создать специализированную версию зависимости — например, добавить префикс ко всем событиям аналитики для конкретного модуля или обернуть общий сервис в адаптер.
Рассмотрим ситуацию: компонент омнибокса (адресная строка) должен логировать события с префиксом omnibox:, чтобы отличать их от событий других компонентов, при этом все подкомпоненты омнибокса должны автоматически использовать эту префиксную аналитику.
Без Implicits:
class OmniboxComponent {
init(analytics: Analytics) {
let omniboxAnalytics = analytics.prefixed("omnibox")
self.suggestionsList = SuggestionsList(analytics: omniboxAnalytics)
self.searchField = SearchField(analytics: omniboxAnalytics)
self.autocomplete = Autocomplete(analytics: omniboxAnalytics)
}
}
Создаётся отдельный omniboxAnalytics, который передаётся во все подкомпоненты и используется как префиксная версия analytics во всех вызовах.
С Implicits:
class OmniboxComponent {
init(_ scope: ImplicitScope) {
let scope = scope.nested()
defer { scope.end() }
// Трансформируем Analytics в версию с префиксом
Implicit.map(Analytics.self, to: Analytics.self) { analytics in
analytics.prefixed("omnibox")
}
// Все подкомпоненты автоматически получат префиксную аналитику
self.suggestionsList = SuggestionsList(scope)
self.searchField = SearchField(scope)
self.autocomplete = Autocomplete(scope)
}
}
Implicit.map берёт существующую implicit‑зависимость (Analytics), трансформирует её через замыкание и регистрирует результат как новую implicit‑зависимость с тем же типом или другим ключом.
Все подкомпоненты внутри вложенного scope видят трансформированную версию — когда они обращаются к @Implicit var analytics: Analytics, они получают версию с префиксом omnibox:, при этом компоненты за пределами OmniboxComponent продолжают видеть оригинальную аналитику без префикса.
Это особенно полезно для создания цепочек трансформаций:
let scope = scope.nested()
defer { scope.end() }
// network -> authenticatedNetwork
Implicit.map(NetworkService.self, to: \.authenticatedNetwork) { network in
AuthenticatedNetworkService(network: network, authToken: "...")
}
// authenticatedNetwork -> apiClient
Implicit.map(\.authenticatedNetwork, to: \.apiClient) { authenticatedNetwork in
APIClient(network: authenticatedNetwork)
}
Удобство: withScope
Во всех примерах выше мы создавали scope вручную и закрывали через defer { scope.end() }. Для удобства существует обёртка withScope, которая предоставляет альтернативный способ записи:
func startApp() {
withScope { scope in
@Implicit var network = NetworkService()
@Implicit var database = DatabaseService()
App(scope).start()
}
// Scope автоматически закрыт после блока
}
Это эквивалентно коду с явным созданием scope и defer:
func startApp() {
let scope = ImplicitScope()
defer { scope.end() }
@Implicit var network = NetworkService()
@Implicit var database = DatabaseService()
App(scope).start()
}
withScope может быть удобнее в коротких функциях или когда scope используется только в ограниченном блоке кода, при этом оба подхода работают одинаково, и выбор между ними — вопрос предпочтений.
Реальный пример из production
Рассмотрим реальный комплексный пример из Яндекс Браузера — построение модели настроек приложения, которая демонстрирует применение всех паттернов работы с Implicits.
BrowserSettings — это модель настроек браузера, агрегирующая десятки секций: поиск, внешний вид, менеджер вкладок, конфиденциальность, голосовой поиск, переводчик, автозаполнение, специальные возможности. Сигнатура инициализатора содержит всего пять параметров:
public init(
debug: Debug,
profileSettings: ProfileSettingsSwitch,
actions: Actions,
passcode: Privacy.Passcode?,
_ scope: ImplicitScope
)
При этом инициализатор и все вложенные компоненты требуют доступ примерно к 96 implicit‑зависимостям, которые доступны через scope.
Рассмотрим, как в этом компоненте применяются паттерны из предыдущего раздела.
Прямое использование @Implicit
Самый простой способ использовать implicit‑зависимости — объявить их прямо в стеке вызовов для принятия решений или для вычислений. Рассмотрим функцию, которая создаёт секцию настроек клавиатуры:
func makeKeyboardSettings(_ scope: ImplicitScope) -> KeyboardSettings? {
@Implicit var keyboardModule: KeyboardModule?
@Implicit(\.keyboardDisabledByPolicy) var disabledByPolicy
guard let module = keyboardModule else {
return nil // Клавиатура недоступна
}
return KeyboardSettings(
module: module,
isEnabled: !disabledByPolicy
)
}
Функция объявляет @Implicit‑зависимости и использует их для условной логики: если keyboardModule отсутствует, возвращается nil, а флаг disabledByPolicy влияет на доступность настроек.
Обратите внимание на @Implicit(\.keyboardDisabledByPolicy) — это именованный ключ, потому что Bool не может использоваться как type key (в контексте может быть много разных boolean‑флагов с разным назначением).
Другой пример — выбор реализации на основе фич‑флагов:
func makeVoiceSearch(_ scope: ImplicitScope) -> VoiceSearch {
@Implicit var voiceEnabled: Bool
@Implicit var assistantAvailable: Bool
if voiceEnabled && assistantAvailable {
return .withAssistant(VoiceAssistant(scope))
} else if voiceEnabled {
return .plain(PlainVoice(scope))
} else {
return .disabled
}
}
Зависимости используются для принятия решения о том, какую реализацию создавать — с голосовым ассистентом, без него или вообще отключить функцию.
Это самый простой способ работы с implicit-зависимостями: объявляются локально, используются внутри функции и не выходят за пределы её контекста.
Захват контекста для lazy properties
Большинство секций настроек создаются лениво — только когда пользователь открывает соответствующий раздел. Для этого используется паттерн захвата контекста через #implicits:
core: Core(
search: Lazy { [implicits = #implicits] in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return Search(
engine: SearchEngine(scope),
suggestions: SearchSuggestions(scope)
)
},
appearance: Lazy { [implicits = #implicits] in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return Appearance(scope)
},
tabManager: Lazy { [implicits = #implicits] in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return TabManager(scope)
}
)
Макрос #implicits захватывает минимальное подмножество зависимостей — статический анализатор определяет, что SearchEngine и SearchSuggestions требуют одни зависимости, Appearance — другие, TabManager — третьи, и каждое lazy‑замыкание захватывает только свой набор.
Когда пользователь открывает секцию «Поиск», lazy property вычисляется, контекст восстанавливается через ImplicitScope(with: implicits) и компоненты получают доступ к нужным зависимостям. Часть секций создаются сразу, часть — лениво:
core: Core(
search: Lazy { ... }, // Ленивое создание
appearance: Lazy { ... }, // Ленивое создание
accessibility: Accessibility(scope), // Создаём сразу
privacy: makePrivacy(passcode, scope) // Создаём через функцию
)
Внутри lazy property можно объявлять дополнительные implicit‑зависимости:
fontSizes: Lazy { [implicits = #implicits] in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
@Implicit var fontSettings: FontSizeSettings
@Implicit var tabUpdater: TabFontSizeUpdating
return fontSettings.makeSettings(updater: tabUpdater)
}
Замыкания с захватом контекста
Некоторые секции создаются условно в зависимости от фич‑флагов, при этом сам выбор происходит через замыкание, которое вычисляется позже:
features: Features(
voiceSearch: voiceFeatureEnabled.map { [implicits = #implicits] enabled in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return if enabled {
.withVoice(VoiceSearch(scope))
} else {
.plain(PlainSearch(scope))
}
},
neuroEditor: neuroFeatureEnabled.map { [implicits = #implicits] enabled in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
return enabled ? NeuroEditor(scope) : nil
}
)
Секция voiceSearch выбирает между двумя реализациями на основе voiceFeatureEnabled, секция neuroEditor создаётся, только если функция включена.
Замыкание захватывает контекст через #implicits и выполняется позже, когда ObservableVariable вычисляет своё значение, при этом нужные implicit‑зависимости остаются доступны.
Nested scope и объявление зависимостей
Некоторые компоненты создают вложенный scope и объявляют в нём дополнительные implicit‑зависимости. Рассмотрим компонент Appearance, который отвечает за настройки внешнего вида:
extension Appearance {
init(_ scope: ImplicitScope) {
let scope = scope.nested()
defer { scope.end() }
// Объявляем зависимости для этого scope
@Implicit var colorScheme: ColorSchemeSettings
@Implicit var appIconSelector: AppIconSelector
@Implicit var networkDeps = NetworkDeps(
parsingQueue: OperationQueue(name: "parsing", qos: .userInitiated),
operationQueue: OperationQueue(name: "network", qos: .userInitiated)
)
// Именованные ключи для флагов
@Implicit(\.suppressInformers) var suppressInformers = false
@Implicit(\.showGeoInformers) var showGeo = true
self.init(
colorScheme: colorScheme.scheme.asObservedProperty(),
appIcon: AppIcon(selector: appIconSelector),
cityServer: RemoteCityServer(scope),
informers: suppressInformers ? nil : Informers(showGeo: showGeo)
)
}
}
Вложенный scope создаётся через scope.nested(), после чего в нём объявляются зависимости, специфичные для компонента Appearance. networkDeps создаётся прямо здесь и регистрируется как implicit‑зависимость, после чего становится доступен в RemoteCityServer и других подкомпонентах через обычный @Implicit var networkDeps: NetworkDeps.
Именованные ключи suppressInformers и showGeoInformers объявляются с явными значениями для этого scope, при этом ключи должны быть предварительно определены в расширении ImplicitsKeys:
extension ImplicitsKeys {
static let suppressInformers = Key<Bool>()
static let showGeoInformers = Key<Bool>()
}
Когда scope закрывается через defer { scope.end() }, все объявленные в нём зависимости становятся недоступны и родительский scope восстанавливается — это гарантирует изоляцию и предотвращает утечки памяти.
Комбинация явных и implicit-параметров
Компоненты могут принимать как явные параметры, так и implicit‑зависимости, выбирая подходящий способ для каждого случая:
extension About {
init(
country: ObservableVariable<UserCountry>, // Явный параметр
_ scope: ImplicitScope
) {
@Implicit var aboutFeatures: AboutPageFeatures
@Implicit var appIconSelector: AppIconSelector
self.init(
recommendations: aboutFeatures.recommendationsURL,
licenseAgreement: aboutFeatures.licenseAgreementURL,
otherApps: aboutFeatures.otherAppsURL,
appIcon: makeAppIcon(selector: appIconSelector, country: country)
)
}
}
Компонент принимает country как явный параметр, потому что он специфичен для этого компонента и используется в нескольких местах, при этом общие сервисы вроде aboutFeatures и appIconSelector получаются через implicit‑зависимости.
Итог
Код BrowserSettings показывает реальное использование всех ключевых паттернов Implicits в production:
прямое использование через
@Implicitдля локальных решений в инициализаторе;захват контекста через
#implicitsдля отложенного создания секций настроек;использование в замыканиях для условного создания компонентов;
вложенные scope для изоляции контекста и объявления дополнительных зависимостей;
комбинация явных и implicit‑параметров в зависимости от того, что более уместно.
Сигнатура инициализатора содержит всего 5 параметров вместо потенциальных 100, при этом каждая секция настроек получает изолированный контекст и объявляет только нужные ей зависимости в момент, когда они действительно требуются.
Ограничения Implicit
Любое решение имеет свои границы применимости и компромиссы, и Implicits не исключение. Важно понимать ограничения библиотеки, чтобы использовать её правильно и не удивляться неожиданному поведению.
Динамическая диспетчеризация
Статический анализатор не может следовать за динамической диспетчеризацией — если вызов идёт через протокол, анализатор не знает, какая конкретная реализация будет вызвана, и не может проверить implicit‑параметры:
protocol Builder {
func build(_ scope: ImplicitScope) -> Component
}
func createComponent(builder: Builder, _ scope: ImplicitScope) {
// Анализатор не знает конкретный тип builder
return builder.build(scope)
}
То же самое касается замыканий — анализатор не может проследить, какие implicit‑параметры нужны внутри callback:
func process(callback: (ImplicitScope) -> Void, _ scope: ImplicitScope) {
callback(scope)
}
В таких случаях пришлось бы полагаться на runtime проверки — если implicit‑параметр отсутствует, получите fatal error с понятным сообщением о том, какая зависимость не найдена.
Явные аннотации типов
Анализатор работает отдельно от компилятора и не имеет доступа к полной информации о типах, поэтому при использовании type keys требуются явные аннотации:
// Не сработает — анализатор не может вывести тип
@Implicit var network = services.networkService
// Нужна явная аннотация типа
@Implicit var network: NetworkService = services.networkService
// Или используйте именованный ключ
@Implicit(\.network) var network = services.networkService
Именованные ключи включают информацию о типе в своём определении, поэтому анализатор не требует дополнительных аннотаций.
Резолвинг функций
Анализатор слабее компилятора Swift в резолвинге вызовов функций — он не всегда может однозначно определить, какая именно функция вызывается, особенно при наличии перегрузок или сложного полиморфизма. Например:
extension Builder {
func build(id: Int, _ scope: ImplicitScope) -> ComponentA {
@Implicit var network: NetworkService
return ComponentA(id: id, network: network)
}
func build(id: String, _ scope: ImplicitScope) -> ComponentB {
@Implicit var database: DatabaseService
return ComponentB(id: id, database: database)
}
}
let component = builder.build(id: someId, scope)
Тут для корректного резолва нужно вывести тип someId, что в общем случае является очень сложной задачей. Поэтому анализатор может выдать ошибку о неоднозначности, и нужно задавать функциям более конкретные имена — buildComponentA и buildComponentB.
Анализатор улучшается и покрывает подавляющее большинство случаев, встречающихся в production‑коде, но иногда нужно помочь ему, используя более явные имена функций.
Соблазн сделать всё implicit
При массовом использовании Implicits появляется соблазн передавать через implicit всё подряд — ведь это так удобно, не нужно прокидывать параметры. Однако важно различать, что должно быть implicit‑зависимостью, а что — явным параметром.
Implicit подходит для:
сервисов и инфраструктуры (network, database, analytics);
конфигурации и feature flags;
сross‑cutting concerns (логирование, метрики);
компонентов, которые нужны на многих уровнях иерархии.
Явные параметры нужны для:
данных, специфичных для конкретного вызова;
бизнес‑логики и состояния;
параметров, которые меняются от вызова к вызову;
значений, важных для понимания работы функции.
// Плохо — бизнес-данные через implicit
@Implicit var userId = "123"
loadUserProfile(scope)
// Хорошо — бизнес-данные явно, инфраструктура implicit
func loadUserProfile(userId: String, _: ImplicitScope) {
@Implicit var network: NetworkService
network.load("/users/\(userId)")
}
Неправильный выбор может сделать код менее понятным — если всё передаётся через implicit, сигнатуры функций становятся пустыми и непонятно, что функция делает и какие данные ей нужны.
Работа на уровне синтаксиса
Анализатор работает на уровне синтаксиса кода, а не на уровне семантики, поэтому теоретически его всегда можно обмануть, если специально постараться — написать код, который будет работать в runtime, но который анализатор не сможет корректно проверить.
Мы стараемся, чтобы таких случаев было как можно меньше, и анализатор покрывает обычный production‑код. Если разработчик просто пишет код и не пытается специально обмануть анализатор, всё должно работать корректно и анализатор должен отлавливать ошибки. Если же разработчик явно пытается обмануть анализатор через запутанные конструкции и сложные паттерны и анализатор не справляется — это в целом не считается багом. Чтобы гарантировать корректность во всех подобных ситуациях, анализатору пришлось бы полностью повторить семантическую логику компилятора Swift, что фактически невозможно в рамках инструмента.
Escaping- и non-escaping-замыкания
Анализатор не различает escaping‑ и non‑escaping‑замыкания — для него все замыкания считаются escaping и требуют явного захвата контекста через #implicits:
// Даже для non-escaping-замыкания требуется захват
items.map { [implicits = #implicits] item in
let scope = ImplicitScope(with: implicits)
defer { scope.end() }
@Implicit var formatter: Formatter
return formatter.format(item)
}
Для non‑escaping‑замыканий, таких как array.map, технически не обязательно захватывать контекст явно — implicit‑параметры остаются доступны в стеке, но анализатор требует явного захвата, потому что не видит разницы между escaping и non‑escaping.
Это не самое оптимальное решение, и мы думаем, как улучшить ситуацию в будущих версиях, но пока приходится явно захватывать контекст для всех замыканий, использующих implicit‑параметры.
Выводы
Implicits — это библиотека для Swift, которая решает проблему прокидывания параметров через длинные цепочки вызовов.
В крупных проектах промежуточные функции вынуждены прокидывать десятки зависимостей транзитом — от корня к листьям графа вызовов, при этом промежуточные слои знают о зависимостях, которые им не нужны, что размывает границы ответственности и усложняет рефакторинг.
Библиотека вводит механизм implicit‑параметров: зависимости объявляются в корне через @Implicit, передаются через легковесный объект ImplicitScope и используются в конечных точках через @Implicit — промежуточные функции больше не знают о транзитных зависимостях.
Ключевые свойства: compile‑time‑проверки через статический анализатор на уровне синтаксиса, сокращение boilerplate‑кода, сохранение гибкости языка Swift за счёт того, что механизм реализован как обычная библиотека, постепенное внедрение без глобального рефакторинга.
И самое главное! Нам настолько понравилось использовать это полезное решение внутри, что не могли не поделиться с сообществом. Поэтому выложили Implicits в опенсорс на GitHub. Интеграция через Swift Package Manager занимает несколько минут:
// Package.swift
dependencies: [
.package(
url: "https://github.com/yandex/Implicits",
from: "1.0.0"
)
]
targets: [
.target(
name: "YourTarget",
dependencies: ["Implicits"],
plugins: ["ImplicitsAnalysisPlugin"]
)
]
Возьмите место в коде, где через несколько функций прокидывается пара‑тройка параметров — например, analytics и logger проходят через три промежуточных слоя:
func buildScreen() {
+ let scope = ImplicitScope()
+ defer { scope.end() }
+ @Implicit var analytics = AnalyticsService()
+ @Implicit var logger = ConsoleLogger()
- let container = buildContainer(analytics: analytics, logger: logger)
+ let container = buildContainer(scope)
}
- func buildContainer(analytics: Analytics, logger: Logger) -> Container {
+ func buildContainer(_ scope: ImplicitScope) -> Container {
- let list = buildList(analytics: analytics, logger: logger)
+ let list = buildList(scope)
}
- func buildList(analytics: Analytics, logger: Logger) -> List {
+ func buildList(_: ImplicitScope) -> List {
+ @Implicit var analytics: Analytics
+ @Implicit var logger: Logger
analytics.trackEvent("list_created")
logger.log("Building list")
}
Попробуйте интегрировать Implicits на отдельном фрагменте — благодаря её простоте она не расползается по проекту и не создаёт скрытых связей. От большинства DI‑библиотек, которые нередко «поглощают» проект и требуют значительных первоначальных вложений в рефакторинг и обучение, Implicits отличается тем, что здесь мы постарались минимизировать эти издержки и сделать внедрение максимально безболезненным. Обычно инструмент быстро приживается и заметно упрощает код. А если вам всё‑таки не подойдёт, то те же свойства, которые позволяют ему легко встроиться в архитектуру, позволяют столь же просто и чисто отказаться от изменений.
Будем рады вашим историям внедрения, предложениям по улучшению и вашим вкладам в репозиторий!
DevilDimon
Про Swift Dependencies от Pointfree - сказано, что необходимо объявлять значения явно, и это подано как минус. Я думаю, это для большинства приложений это плюс, потому что лишает необходимости в статическом анализаторе типа такого из статьи (код может упасть только при циклической зависимости).
Где это реально мешает - это при трансформации и/или переопределения зависимостей по ходу графа или в рантайме. Я вообще считаю, что так лучше не делать - большинство приложений может обойтись без этого, там нет режима инкогнито или каких-то перезагружающихся движков не с самим приложением целиком. А если есть, я бы их вручную прокинул (и делал так).
Потом сказано, что у Swift Dependencies графа не видно. Не очень понятно, как он становится виден из параметра scope. Как бы скрытие явного графа в коде и есть одна из главных целей. Может, имеется в виду, что граф видит статический анализатор и его можно распечатать в рандомном месте в коде - тогда это круто (правда я ни разу не сталкивался с желанием это делать, даже потребляя 20-30 параметров - граф полезно бы глянуть если что-то не так с переопределением, но про это уже писал выше).
Я к чему - 95% приложений могут использовать Swift Dependencies для зависимостей типа долгоживущих сервисов и вручную прокидывать все остальное, даже если там миллионы строк кода.
AlfredZien Автор
Спасибо за развёрнутый комментарий, давайте попробуем разобраться.
Важно понимать, что Dependencies в том виде, в котором библиотека предполагается к использованию, не реализует принцип dependency inversion, а является удобной обёрткой для синглтонов, контейнером глобальных переменных с механизмом переопределения. Это легко проверить: попробуйте заинжектить в модуль А реализацию из модуля Б так, чтобы А не зависел от Б, и вы увидите что
liveValueдолжен быть определён при объявлении ключа, а значит зависимость на конкретный тип никуда не девается.Если же пытаться использовать Dependencies для настоящего dependency inversion, то есть ставить заглушки как дефолтные значения и переопределять всё честно через
withDependencies, то опыт получается не очень: не видно какие зависимости нужно переопределять, они скрыты внутри вызываемого кода, и если что-то забыли — узнаете только в рантайме. По сути получаются те же имплиситы, только без статического анализатора и с менее удобным API.Point-Free делают классные инструменты, и Dependencies — продуманный компромисс между удобством и тестируемостью, который для многих проектов отлично подходит, просто для нас он не подошёл. Говорить о пользе dependency inversion выходит за рамки статьи, но утверждать что он не нужен 95% приложений — всё-таки смелое заявление :)
DevilDimon
Это неправда. Можно законфрмить
TestDependencyKeyв модуле Б (он требует толькоtestValue),liveValueнаписать в корневом модуле, а модуль А будет лишь экспортировать интерфейс, адаптером к которому этотliveValueи будет. Корень зависит от А и Б, а сами А и Б друг от друга не зависят. С интерфейс-модулями тоже совместимо (у них в документации этот подход как раз используется для реализации интерфейс-модулей). Короче, библиотека ничего не навязывает.withDependenciesи переопределение вообще не нужны за пределами тестов и перезапускаемых (не-синглтон) зависимостей, которых в обычных приложениях почти нет. Про это и был мой комментарий в общем-то. Если вы пишете браузер – проблема. Но браузер почти никто не пишет.