18.02.2025
Сегодня поговорим о том, почему сущности вырастают. Вроде бы маленькие и понятные, через какое-то время они становятся большими и непонятными. Напоминает ситуацию, как будто вы приручили маленького котенка, а он потом вырос в саблезубого тигра. Почему так происходит и что делать? Постараюсь на простых примерах поразмышлять над этим.
Почему?
Но начнем чуть издалека, рассмотрим такой пример. Нам надо включать лампочку. Оставим за скобками вечный вопрос сколько программистов для этого надо, и просто напишем код.
class Lamp {
private(set) var isOn: Bool = false
func switchOn() {
isOn = true
// do the actual switch
}
func switchOff() {
isOn = false
// do the actual switch
}
}
Вроде бы все отлично и все понятно (я намеренно опустил логику переключения, она тут вторична). Эта лампочка уходит в релиз и все работает, все довольны.
Но это не конец истории, через, скажем, месяц, приходит продакт и говорит, а теперь нам нужны диммируемые лампочки (это такие у которых яркость можно измерять, очень полезно). И вроде бы отлично, диммируемые лампочки - это хороший продукт. Мы засучиваем рукава и смело в бой. Модифицируем наш класс
class Lamp {
let isDimmable: Bool
private(set) var isOn: Bool
private(set) var dimValue: Double
init(isDimmable: Bool, isOn: Bool = false, dimValue: Double = 0.0) {
self.isDimmable = isDimmable
self.isOn = isOn
self.dimValue = dimValue
}
func switchOn() {
isOn = true
// do the actual switch
}
func switchOff() {
isOn = false
// do the actual switch
}
func dim(value: Double) {
if (value > 0) {
switchOn()
}
dimValue = value
// do the actual dim
}
}
и такой код даже будет работать. И вы даже быстро его напишите, и даже релиз случится. Но как мы видим, уже тут есть проблемы:
- комбинаторика состояний isOn и dimValue. Я добавил некоторую проверку в функции dim(value:) но легко мог ее забыть. И тогда лампочка начала бы вести себя странно.
- любая простая лампочка начинает знать про dimValue даже если она не dimmable.
прежде чем это все начинать фиксить, давайте еще усложним проблему. Продакты добавляют линейку устройств реле. Реле позволяет управлять не только светом, а например, чайником. Или светом но в выключателе. Или любым другим устройством которое можно включить в розетку. Отлично! Давайте чуть перепишем наш класс.
class SomethingSwitchable {
let isDimmable: Bool
let isRelay: Bool
let switchCallback: ((_ isOn: Bool) -> Void)?
private(set) var isOn: Bool
private(set) var dimValue: Double
init(isDimmable: Bool, isRelay: Bool, isOn: Bool = false, dimValue: Double = 0.0, switchCallback: ((_ isOn: Bool) -> Void)?) {
self.isDimmable = isDimmable
self.isRelay = isRelay
self.switchCallback = switchCallback
self.isOn = isOn
self.dimValue = dimValue
}
func switchOn() {
isOn = true
// do the actual switch
switchCallback?(isOn)
}
func switchOff() {
isOn = false
// do the actual switch
switchCallback?(isOn)
}
func dim(value: Double) {
if (value > 0) {
switchOn()
}
dimValue = value
// do the actual dim
}
}
class Lamp: SomethingSwitchable {
init(isDimmable: Bool, isOn: Bool = false, dimValue: Double = 0.0) {
super.init(isDimmable: isDimmable, isRelay: false, isOn: isOn, dimValue: 0.0) { isOn in
switch isOn {
case false: // do the actual switch off
case true: // do the actual swift on
}
}
}
}
class Relay: SomethingSwitchable {
init(isOn: Bool = false, switchCallback: ((Bool) -> Void)?) {
super.init(isDimmable: false, isRelay: true, isOn: isOn, dimValue: 0.0, switchCallback: switchCallback)
}
}
Я даже показал, как можно вынести общую логику в “базовый класс”. Но по приведенному коду видно что мы пошли не туда. И наша маленькая приятная лампочка превратилась в страшное нечто.
Как поправить?
Для начала давайте разберем мелкие правки которые можно было сделать, чтобы чуть спасти ситуацию. Эти техники могут быть полезны в целом взять на вооружение.
Я упоминал комбинаторику состояний isOn и dimValue. Пока опустим то, что лампочка и диммируемая лампочка могут быть разными сущностями, просто попробуем исправить ситуацию.
Для этого, обозначим, что isOn и dimValue не являются полностью независимыми, в данном примере dimValue имеет смысл только тогда, когда isOn = true. И, как гласит принцип “явное лучше неявного”, сделаем это явно и перепишем код так:
class Lamp {
enum LampState {
case off
case on(value: Double)
}
let isDimmable: Bool
private(set) var state: LampState
init(isDimmable: Bool, state: LampState) {
self.isDimmable = isDimmable
self.state = state
}
func switchOn() {
state = .on(value: 1.0)
// do the actual switch
}
func switchOff() {
state = .off
// do the actual switch
}
func dim(value: Double) {
state = .on(value: value)
// do the actual dim
}
}
За счет явного введение LampState мы отразили любому читающему наш код, как работает состояние нашей лампочки. И даже код в dim(value:) стал более понятным - мы явно включим лампочку и включим ее в нужное состояние сразу (она не будем промаргивать через состояние 1.0).
Также можно явно заметить, что тип Double по области значений явно шире, чем нам надо. Явно диммирование управляется через диапазон 0-1. И это тоже можно явно отразить, опять же, явное лучше неявного.
struct DimValue {
private static let minimum = 0.0
private static let maximum = 1.0
private(set) var value: Double
init(value: Double) {
self.value = Self.clamp(value, min: Self.minimum, max: Self.maximum)
}
private static func clamp(_ value: Double, min: Double, max: Double) -> Double {
return Swift.max(min, Swift.min(value, max))
}
mutating func setValue(_ newValue: Double) {
value = Self.clamp(newValue, min: Self.minimum, max: Self.maximum)
}
public static let max = DimValue(value: Self.maximum)
}
class Lamp {
enum LampState {
case off
case on(value: DimValue)
}
let isDimmable: Bool
private(set) var state: LampState
init(isDimmable: Bool, state: LampState) {
self.isDimmable = isDimmable
self.state = state
}
func switchOn() {
state = .on(value: .max)
// do the actual switch
}
func switchOff() {
state = .off
// do the actual switch
}
func dim(value: DimValue) {
state = .on(value: value)
// do the actual dim
}
}
Таким образом мы увидим несоответствие кода сразу же, да еще и компилятором проверим.
Меня можно укорить в том, что я на самом деле увеличил объем кода по сравнению с моей же реализацией диммируемой лампочки. Да, кода стало чуть больше, но он стал более явно выражать то, что он делает, без предположений существующих только в головах.
Теперь давайте рассмотрим более фундаментальную проблему - у нас все лампочки диммируемые. Даже те, которые нет. И на самом деле поле isDimmable не сильно помогает решить проблему, потому что как вы можете заметить, оно устанавливается в конструкторе, но нигде не используется - про него забыли.
И попутно непонятно, а как теперь быть лампочкам недиммируемым? Игнорировать значение диммера?
Давайте начнем приводить код в чувство, попутно учитывая будущее когда у нас появится реле.
Формально - у нас есть три сущности:
- Что-то что может включаться
- Что-то что может диммироваться
- Что-то что может включать других (ака реле)
На самом деле наш код базовой реализации класса SomethingSwitchable дает еще и четвертый вариант, что-то вроде “диммируемого реле”, которое на самом деле может быть регулятором скорости/яркости.
Попутно вспомним Interface Segregation Principle и заведем такие сущности.
Для начала что-то, что может переключаться
protocol Switchable {
var isOn: Bool { get }
func switchOn()
func switchOff()
}
Это может быть лампочка, может быть реле. Далее, что-то что можно диммировать
protocol Dimmable {
var dimValue: DimValue { get }
func dim(value: DimValue)
}
И наконец, что-то, что может управлять другими
protocol Controling {
func setSwitchCallback(_ switchCallback: @escaping (_ isOn: Bool) -> Void)
}
Теперь у нас есть базовые кубики, из которых мы можем строить что-то более сложное.
Простую лампочку сделаем так
class YablochkovLamp: Switchable {
private(set) var isOn: Bool = false
func switchOn() {
isOn = true
// do the on logic
}
func switchOff() {
isOn = false
// do the off logic
}
}
Заметьте, она может включаться/выключаться, ее состояние простое. Оно не искажается состоянием диммируемой лампочки и может использовать простое булево значение (вопросы thread-safety пока опустим).
Диммируемая лампочка будет такой:
class DimmableLamp {
private enum State {
case off
case on(value: DimValue)
}
private var state: State = .off
}
extension DimmableLamp: Switchable {
var isOn: Bool {
switch state {
case .off: return false
case .on: return true
}
}
func switchOn() {
state = .on(value: .max)
// do the logic
}
func switchOff() {
state = .off
// do the logic
}
}
extension DimmableLamp: Dimmable {
var dimValue: DimValue {
switch state {
case .off: return .min
case let .on(value): return value
}
}
func dim(value: DimValue) {
state = .on(value: value)
// do the logic
}
}
Тут отметим, что на уровне типов можно явно отметить, что эта лампочка диммируемая, теперь сложно об этом забыть. Кроме того, я показал, что с помощью extension’ов можно расширить логику после создания типа (ретроактивно). И в довесок спрятал от внешнего наблюдателя внутреннее состояние лампочки.
Ну и теперь реле:
class Relay {
private(set) var isOn: Bool = false
private var switchCallback: ((_ isOn: Bool) -> Void)?
}
extension Relay: Switchable {
func switchOn() {
isOn = true
switchCallback?(isOn)
}
func switchOff() {
isOn = false
switchCallback?(isOn)
}
}
extension Relay: Controling {
func setSwitchCallback(_ switchCallback: @escaping (Bool) -> Void) {
self.switchCallback = switchCallback
}
}
Как видим состояние реле проще состояние диммируемой лампочки (не надо явно учитывать сайд эффект и понятно), но при этом функциональность реле реализована так, как надо.
Такая детализация позволяет:
- Строить сущности из кубиков (я не упоминул еще о дефолтных реализация в протоколах, что тоже улучшает ситуацию)
- Уменьшать комбинаторный взрыв за счет разделения и в целом упрощать реализацию сущностей
- Заменять конкретные классы в процессе развития кода на другие, удовлетворяющие контракту.
Про третий пункт отдельно. Например, лампочка у нас “уехала” на другой хост в ip сети, и чтобы скрыть от управляющего кода этот факт и не делать его сложнее чем надо, мы можем изобрести такую связку проксей:
class SwitchableProxyClient: Switchable {
// private let network: NetworkClient
var isOn: Bool {
// network.proxyIsOn
return false
}
func switchOn() {
// network.proxySwitchOn()
}
func switchOff() {
// network.proxySwitchOff()
}
}
class SwitchableProxyServer {
private let switchable: Switchable
// private let networkServer: NetworkServer
init(switchable: Switchable) {
self.switchable = switchable
// networkServer.switchOn = {
// switchable.switchOn()
// }
// networkServer.switchOff = {
// switchable.switchOff()
// }
// networkServer.isOn = {
// switchable.isOn
// }
}
}
и благодаря им, управляющий код не будет знать, что лампочка “уехала”, а в проксе можно реализовать гарантии надежности для всех, кто может быть Switchable.
А мораль?
А мораль в том, что, когда вы пишите или что еще более важно модифицируете какую-то сущность, хорошо бы подумать о том, как ваша модификация сказывается на том, чем сущность является. Не превращаете ли вы ее в франкенштейна и может быть ее стоит подробить на концепции и эти концепции использовать.
В конце концов, то как вы обозначили контракт вашей сущности влияет на то, как ее используют. И как понимают в целом, что это.
08.04.2024
Тема внедрение зависимостей одна из краеугольных в целом при любой разработке. И, благодаря этому, одна из наиболее дискуссионных, порой доходящаяя до уровня “священных войн” между апологетами разных подходов. В этой статье постараюсь изложить свое видение подходов к решению проблем.
Преамбула
Для того, чтобы не превращать эту статью в рассказ “от сотворения мира”, очень рекомендую посмотреть это видео (с указанного таймкода, лекция там в целом про архитектуру, но нас в рамках этой статьи будут интересовать выкладки про DI).
Постановка проблемы
Базовая задача
Итак, мы стараемся придумать решение следующей проблемы
-
protocol SomeBookService { /* ... */ }
class BookProcessor {
let service: SomeBookService = SomeBookServiceImpl()
}
-
interface SomeBookService { /* ... */ }
class BookProcessor(
private val service: SomeBookService = SomeBookServiceImpl()
)
Что мы хотим от хорошего решения?
- Через протокол/интерфейс. Использующий зависимость класс не должен знать о конкретной реализации и должен иметь работать с любой реализацией правильно реализующей контракт (объявленный в протоколе/интерфейсе).
- Узкий контракт. Реализация, которую мы получаем в виде зависимости должена иметь минимально необходимый контракт. Наша сущность не должна видеть лишнего
- Необходимость и достаточность. Поставляемые зафисимости поставляются в необходимом и достаточном объеме, не требуется каких-то специальных приседаний для того, чтобы получить что-то еще (это как правило относится к запрету использовать внутри синглтоны).
- Реализация DI для логического кода “невидима”. Мы не должны видеть фрагменты порождающего кода где-либо (кроме некоторых исключений).
Платформенные особенности
Мы рассматриваем мобильные платформы (в целом можно рассматривать и серверные, но там как правило все проще в этом плане).
iOS
Как правило, все сущности iOS позволяют нормально реализовать constructor injection паттерн без необходимости построения специальных решений. Иногда требуются специальные действия для создания циклических зависимостей, но они легко решаются способами, описанными ниже.
Единственный объект, который система создает сама - это UIApplication (и applicationDidFinishLaunchingWithOptions) или SwiftUI объект обозначенный как @main. Но эти объекты будем считать точкой входа и растить графы от них.
Android
У Android также как и в iOS есть главный объект создаваемый системой - Application, но есть и важное отличие - в Android есть объекты, которые фреймворком могут быть убиты и пересозданы через тривиальный конструктор, такие как Activity, Fragment, View и тд. И есть общепринятая практика использовать для иньекции зависимостей в такие объекты через паттерн Service Locator. Service Locator считаем антипаттерном (как минимум он противоречит принципам хорошего решения, упомянутым выше).
Поэтому для решение проблемы спец сущностей (которые могут быть пересозданы), в Android надо предусмотреть дополнительные средства.
O Service Locator’е
Service Locator это такой паттерн проектирования, который, упрощенно говоря, предоставляет единую точку, через которую можно запросить разные зависимости. В рамках создания библиотек для DI он обычно соседствует с паттерном Singleton предоставляя публично известный разделяемый объект, из которого можно запросить практически все что угодно.
Я считаю его антипаттерном в применении к задачам DI (у него есть другая применимость, которая норм).
Но чтобы не быть голословным, давайте рассмотрим поставленные требования к хорошему решению:
- Через протокол/интерфейс - эту задачу в целом можно решить, отдавая из публичного singleton’а сервис локатора интерфейсы
- Узкий контракт - так как точка эта общеизвестная, то надо отдавать зависимость целиком, что нарушает принцип сужения контракта. Можно из публичного singleton’а отдавать одну реализацию под набором интерфейсов, но тогда у нас будет очень сложно выглядеть общий контракт такого объекта, сложно будет что-то в нем найти.
- Необходимость и достаточность - мы показываем всевозмоюжные интерфейсы или даже реализации, что любой клиент может видеть все приложение, что явно нарушает это требование
- “Невидимость” - требование явно нарушается, так как из любой точки приложения можно публичный singleton локатора
Таким образом Service Locator использовать не стоит, поэтому будем строить решение на базе концепции контейнера.
О библиотечных решениях
В мире написано огромное количество библиотек, заявляющих что так или иначе решают проблему внедрения зависимостей. Однако большая часть из них основана на паттерне Service Locator. Справедливости ради стоит сказать, что наиболее популярная библиотека Dagger 2 для Android пытается использовать концепцию контейнера, и в Sevice Locator его чаще всего превращают при использовании.
Идея решения проблемы
Как уже было сказано выше - будем использовать концепцию контейнера.
Контейнер - это такая сущность, которыя
- Создает нужные объекты и управляет их временем жизни
- Настраивает связи между ними
- Невидима для самих объектов
Каждая конкретная логическая сущность (кроме самих контейнеров) заявляет необходимость зависимостей (через constructor или property injection) и все. Никоим образом логическая сущность не получает доступа не к типу контейнера, ни к его инстансу (кроме как через Factory интерфейсы там где нужна prototype зависимость).
Отступление про нейминг. Однажды я увидел, как в коде контейнеры называют графами (Graph) и мне эта идея так понравилась, что далее буду называть типы контейнеров графами, можно в рамках этой заметки считать эти термины эквивалентными для задач DI.
Таким образом простейший контейнер может выглядеть так
-
final class SomeGraph {
// 'singleton' entities
private let dep1: Dep1
private let dep2: Dep2
private let dep3: Dep3
// other graphs
private let subGraph: SomeSubGraph
init(
someGraphDependencies: SomeGraphDependencies,
someSpecificDependency: SomeSpecificDependency,
configuration: SomeGraphConfiguration,
...
) {
self.dep1 = Dep1(
/* ... */,
makeSome: {
return self.makeSomePrototypeDep(val: configuration.val)
}
)
self.dep2 = Dep2(/* ... */, useVal: configuration.useVal)
self.dep3 = Dep3(dep1: Dep1, dep2: Dep2)
// ...
self.subGraph = SomeSubGraph(
/* ... */
)
}
deinit {
// тут некоторая логика очистки сущностей, которые того требуют
}
// 'prototype' entities
private func makeSomePrototypeDep(val: Val1) -> Dep4 {
// ...
return Dep4(
val: val
)
}
}
-
class SomeGraph(
someGraphDependencies: SomeGraphDependencies,
someSpecificDependency: SomeSpecificDependency,
private val configuration: SomeGraphConfiguration,
...
) {
// 'singleton' entities
private val dep1: Dep1
private val dep2: Dep2
private val dep3: Dep3
// other graphs
private val subGraph: SomeSubGraph
init {
this.dep1 = Dep1(
/* ... */,
makeSome = {
return makeSomePrototypeDep(val = configuration.val)
}
)
this.dep2 = Dep2(/* ... */, useVal = configuration.useVal)
this.dep3 = Dep3(dep1 = Dep1, dep2 = Dep2)
// ...
this.subGraph = SomeSubGraph(
/* ... */
)
}
fun cleanup() {
// в Java/Kotlin нет полноценных деструкторов, поэтому cleanup методы
// необходимо будет вызывать руками
// тут некоторая логика очистки сущностей, которые того требуют
}
// 'prototype' entities
private fun makeSomePrototypeDep(val: Val1): Dep4 {
// ...
return Dep4(
val = val
)
}
}
И все - собирайте нужную конструкцию из иерархических графов. Когда какой-то набор сущностей станет ненужным - зануляете граф и все очищается (в Android не забываем звать cleanup).
PROFIT? не совсем, есть тонкости
iOS
В iOS как правило все проходит без проблем. Мы создаем корневой граф-контейнер в applicationWill/DidLaunchingWithOptions и передаем его целиком или частями в дальнейшие сущности, или создаем его в корневом объекте помеченном @main и также передаем целиком или частями дальше. Главное не забывать о сужении интерфейсов и принципе достаточной необходимости.
Android
Для бизнесовых сущностей достаточно также в рамках класса Application создать корневой граф и действовать аналогично iOS, но, как я уже выше упоминал, есть проблемы с андроидными сущностями. Поэтому для них нужно специальное решение.
Заведем пару контрактов
// любая сущность, в которую наш DI сможет что-то инжектить
interface Injectable
// контракт сущности контейнера, который сможет что-то куда-то инжектить
interface Injector {
fun inject(into: Injectable) // можно опционально return Result
}
Чуть выше в разделе про Service Locator я уже говорил, что основная проблема в нем в том, что
- его shared instance известен публично, и может быть использован скрыто. С этим увы ничего не поделать, андроидные компоненты устроены так, что нам придется пойти на открытие какого-то shared instance. Для activity/fragment есть лазейка, о ней чуть позже, но в общем итоге не поделать ничего.
- из него можно достать что угодно, эту проблему будем решать.
Также важно, что для Android компонентов инъекция будет асинхронной относительно вызова конструктора объектов, поэтому полноценную compile time проверку мы сделать не сможем. Ограничимся сокрытием лишней информации.
Теперь нам нужно предусмотреть сущность, которую мы будем видеть через shared instance, но не моч из нее ничего достать
class SomeGraph private constructor(
private val dependency: SomeDependnecy,
// other deps
): Injector {
override fun inject(target: Injectable) = when (target) {
is SomeKnownFragment -> {
target.dependency = dependency
}
else -> {
// report error in some kind
}
}
companion object {
var sharedInstance: SomeGraph? = null
private set
fun setup(
dependency: SomeDependency
) {
synchronized(this) {
if (sharedInstance != null) {
return
}
sharedInstance = SomeGraph(
dependency = dependency
)
}
}
}
}
И теперь в условном фрагменте мы можем написать так
class SomeKnownFragment: Fragment(), Injectable {
lateinit var dependency: Dependency? = null
override fun onAttach(context: Context) {
super.onAttach(context)
SomeGraph.sharedInstance?.inject(this)
}
Как я уже сказал, есть некоторая проблема в том, что связывание будет проверено только в runtime, а правильнее было бы сделать в compile time, но архитектура компонентов Android’а тут этому препятствует.
К слову такой подход хорошо подходит для библиотек, этот SomeGraph может быть объектов библиотеки, которую надо проинициализировать и потом сама библиотека будет ее использовать. Для Activity/Fragment и тд можем сделать лучше, для этого нам потребуется еще немножко сахара (покажу на примере Activity, для фрагментов можно сделать аналогично)
fun Activity.inject() {
val thisAsInjectable = this as? Injectable ?: throw InjectException("activity not injectable")
val application = application ?: throw InjectException("application isn't set to activity")
val injector = application as? Injector ?: throw InjectException("application is not an injector")
injector.inject(thisAsInjectable)
}
теперь надо класс приложения разметить соответствующим образом
class YourApp: Application(), Injector {
private lateinit var rootGraph: RootGraph
override fun onCreate() {
super.onCreate()
rootGraph = RootGraph(this)
}
override fun inject(target: Injectable) {
if (!this::graph.isInitialized) {
error("Application isn't properly initialized yet")
}
graph.inject(target)
}
}
корневой граф будет выглядеть примерно так
class RootGraph(
applicationContext: Context
): Injector {
// private val yourdeps = ...
init {
// тут инициализация нужных компонентов
}
override fun inject(target: Injectable) {
when (target) {
is MainActivity -> {
target.depepdency = ...
}
// можно делегировать что-то субграфам
}
}
}
И в самой активити будет просто
class MainActivity : Activity(), Injectable {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
inject()
// остальная инициализация
}
}
Никаких синглетонов и никакого раскрытия лишних зависимостей - PROFIT.
P.S. Если придумаете как решить эту проблему с аналогичными гарантиями да еще и в compile time буду благодарен
06.11.2019
Внедрение зависимостей очень горячая тема в любой области разработки, где мы пишем что-то более сложное чем Hello, World. Однако несмотря на казалось бы изученный вдоль и поперек вопрос, вариантов его решения вы можете на просторах интернета найти великое множество. И в каждом месте оно подается как единственно правильное. И как же выбрать? Предлагаю в этой статье немножко рассмотреть подходы, их плюсы и минусы, немножко поиграться со Swift’ом вообще и попробовать его новые фичи в виде @PropertyWrapper’s.
Итак, постановка задачи у нас будет такая - у нас есть два класса BooksRenderer, который просто каким-то образом рисует книжки, и BooksProvider, который ему их поставляет. На Swift это будет выглядеть примерно так:
final class BooksRenderer {
let provider: BooksProvider = ... /* за это троеточие и будет вестись основная борьба */
func draw() {
let books = provider.books
/* тут каким-то образом рисуются книги из массива books */
}
}
protocol BooksProvider {
var books: [Book] { get }
}
Будем также считать что есть некая реализация протокола BooksProvider, например такая наивная
final class NaiveBooksProvider: BooksProvider {
var books: [Book] {
return [
Book(title: "Dune", author: "Frank Herbert"),
Book(title: "Lord of the Rings", author: "John R.R. Tolkien")
]
}
}
Теперь наша задача каким-то образом доставить экземпляр класса NaiveBooksProvider в BooksRenderer. Самый наивный подход такой, создать экземлляр класса прямо на месте:
final class BooksRenderer {
let provider: BooksProvider = NaiveBooksProvider()
}
Несмотря на то, что этот подход, каким бы наивным он не был, много где применяется, у него есть очевидные недостатки:
- Мы можем захотеть как-то менять конкретный класс реализации, а он тут “прибит гвоздями”
- Мы можем захотеть unit-протестировать класс BooksRenderer, и тогда вместо провайдера захотим вставить какой-нибудь мок
и т.д.
Нам надо что-то лучше. И много где предлагают хорошо известный паттер ServiceLocator. Если его применить, то выглядеть это будет примерно так:
final class ServiceLocator {
static let booksProvider: BooksProvider = NaiveBooksProvider()
}
final class BooksRenderer {
let provider: BooksProvider = ServiceLocator.booksProvider
}
Уже лучше, ответственность за выбор конкретного класса мы достали из BooksRenderer и наделили этой почетной обязанностью класс ServiceLocator. И мы даже можем сделать разные ServiceLocator’ы для основного приложения и для тестов, которые будут создавать разные BooksProvider’ы, однако:
- Теперь 90% кода будет знать про класс ServiceLocator
- Класс ServiceLocator будет огромным (кто там что говорил про Massive View Controller?, у нас тут Massive Service Locator)
Прежде чем пойти дальше, давайте сделаем некоторое лирическое отступление, разберемся в терминологии зависимостей. Вообще внедряемых зависимостей может быть два типа: прости хоспади singleton (но это не то что вы подумали) и prototype. “singleton” зависимости - это такие зависимости, которые сколько бы вы не внедряли в рамках одного конкретного модуля, это всегда будет один экземпляр. “prototype” же - даст на каждую точку внедрения новый экземпляр.
Поэтому если говорить про наш пример с ServiceLocator’ом, то например booksProvider - это singleton зависимость, а bookUpdateOperation - prototype:
final class ServiceLocator {
static let booksProvider: BooksProvider = NaiveBooksProvider()
static func bookUpdateOperation() -> Operation & BookUpdate {
return NaiveBookUpdateOperation(...)
}
}
Теперь давайте сделаем еще одно лирическое отступление, подчерпнутое мной когда я еще занимался “кровавым” enterprise и работал с Srping Framework. Хороший DI контейнер это такой контейнер, который не видно. Тут можно еще пофилософствовать и вспомнить ТРИЗ с ее идеальным конечным результатом, который на наш DI’ный контекст перефразируется так: “хороший DI контейнер - это такой, которого нет, а зависимости внедряются”.
Таким образом, можно сделать такой DI на базе initializer injection (оно лучше property injection, потому что компилятор в этом случае не даст вам озорничать, а с property injection легко забыть что-нибудь присвоить и грохнуться в рантайме):
final class AppContainer {
let booksProvider: BooksProvider
init(with appDelegate: AppDelegate) {
booksProvider = NaiveBooksProvider(...)
appDelegate.booksRenderer = BooksRenderer(provider: booksProvider)
}
}
@UIApplicationMain
final class AppDelegate: UIResponder {
let container: AppContainer
var booksRenderer: BooksRenderer!
func applicationDidFinishLauncherWithOptions(...) {
container = AppContainer(with: self)
}
}
final class BooksRenderer {
let provider: BooksProvider
init(provider: BooksProvider) {
self.provider = provider
}
}
Причем такой подход будет гарантировать вам проверку компилятором. И при этом про AppContainer будет знать только AppDelegate. Да, корневые зависимости в самом AppDelegate’е будут force unwrapped (что исть не хорошо, но лучше я не придумал), но эта вольность доступна только тут.
prootype зависимости в таком подходе можно оформить либо в виде фабрики
final class SomeFactory {
let superDep: SuperDep
init(superDep: SuperDep) {
self.superDep = superDep
}
func makeSomeDep(...) -> SomeDep {
return SomeDep(superDep, ...)
}
}
и потом внедрять это фабрику как singleton зависимость туда где нужно генерить prototype’ные, или в виде замыкания
typealias SomeFactory = (_ superDep: SuperDep, ...) -> SomeDep { SomeDep(superDep, ...) }
и также ее внедрять как singleton зависимость.
Но я обещал немножко Swift 5.1 и @PropertyWrapper, их легко сделать так (пусть будет наш пример с ServiceLocator’ом, хотя его можно легко модифицировать):
@propertyWrapper
public class Inject<Dep> {
private let name: String?
private var kept: Dep?
public var wrappedValue: Dep {
kept ?? {
let dependency: Dep = ServiceLocator.resolve(for: name)
kept = dependency
return dependency
}()
}
public convenience init() {
self.init(nil)
}
public init(_ name: String?) {
self.name = name
}
}
final class ServiceLocator {
static func register<T>(for name: String, resolver: @escaping () -> T) {
// register
}
static func resolve<T>(for name: String?) -> T {
// do some magic
}
}
final class BooksRenderer {
@Inject private var provider: BooksProvider
}
И вуаля! Однако несмотря на всю прелесть такой магии, есть проблема в месте где творится магия (“do some magic”). Если вы вдруг забыли сделать register, то упс, вы получаете рантайм крэш. И сделать это красиво с compile time check непонятно как, так как регистрация динамическая.
Предлагаю подискутировать, оформляйте issues тут
P.S.
08.05.2019
Какое-то время назад я написал статью Разрабатываем и отлаживаем С++ в Docker с помощью VSCode в котором для сборки использовался gcc и для отладки gdbserver. В этом обновленном посте хочу затронуть такой же вопрос, но уже на базе clang 8 и lldb-server.
Также в отличии от предыдущей статьи мы не будем каждый раз пересоздавать контейнер, а запустим его один раз и будет с ним работать через docker exec
.
Итак, начнем, Dockerfile
FROM ubuntu:18.04
RUN apt-get update
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y build-essential xz-utils curl cmake
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y lldb
RUN curl -SL http://releases.llvm.org/8.0.0/clang+llvm-8.0.0-x86_64-linux-gnu-ubuntu-18.04.tar.xz | tar -xJC . && \
mv clang+llvm-8.0.0-x86_64-linux-gnu-ubuntu-18.04 clang_8.0.0 && \
mv clang_8.0.0 /usr/local
ENV PATH="/usr/local/clang_8.0.0/bin:${PATH}"
ENV LD_LIBRARY_PATH="/usr/local/clang_8.0.0/lib:${LD_LIBRARY_PATH}"
WORKDIR /opt/build
VOLUME ["/opt"]
lldb (для lldb-server) используем поставляемый в пакетах (на момент написания статьи для 18.04 это был LLDB 6). Также из пакетов ставим cmake (вроде ничего специфичного пока не надо). clang ставим руками, но уже собранный.
Дальше нам нужен скрипт сборки контейнера (/scripts/docker_build.sh)
#!/bin/bash
cwd=$(pwd)
docker build \
-t something/cmaker \
.
И таска для vscode
{
"label": "build docker container",
"command": "${workspaceFolder}/scripts/docker_build.sh"
}
после этого нам понадобится запустить контейнер для последующего использования, для этого нам нужен скрипт (/scripts/docker_run.sh). Тут попутно запускается lldb-server, о нем позже:
#!/bin/bash
cwd=$(pwd)
docker stop сmaker
docker rm сmaker
docker run \
-dt \
--name сmaker \
-p 7000:7000 \
-p 7001:7001 \
-p 7002:7002 \
-p 7003:7003 \
-v ${cwd}:/opt \
--privileged \
something/cmaker \
"${@}"
и таска для vscode
{
"label": "run container for future builds",
"command": "${workspaceFolder}/scripts/docker_run.sh",
"args": [
"lldb-server", "platform", "--server", "--listen=0.0.0.0:7000", "-m", "7001", "-M", "7003"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
После этого можем приступать к сборке проекта. Структуру проекта сразу предусмотрел для многомодульного проекта
include
scripts
docker_build.sh
docker_run.sh
hword
include
CMakeLists.txt
main.cpp
CMakeLists.txt
Dockerfile
Корневой CMakeLists.txt выглядит так
cmake_minimum_required(VERSION 3.0)
project(something)
set(CMAKE_BINARY_DIR ${CMAKE_SOURCE_DIR}/build)
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR})
set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR})
set(PROJECT_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/include)
include_directories("${PROJECT_INCLUDE_DIR}")
include_directories("${PROJECT_SOURCE_DIR}")
add_subdirectory(hword)
CMakeLists.txt для hword выглядит так
cmake_minimum_required(VERSION 3.0)
project(hword)
set(PROJECT_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/include)
set(HWORD_VERSION_MAJOR 1)
set(HWORD_VERSION_MINOR 0)
set(PROJECT_HWORD_SRCS
${PROJECT_SOURCE_DIR}/main.cpp
)
include_directories("${PROJECT_BINARY_DIR}")
add_executable(${PROJECT_NAME}_r ${PROJECT_HWORD_SRCS})
include_directories("${PROJECT_INCLUDE_DIR}")
Обратите внимание что к имени проекта добавлен постфикс _r
Для примера содержимое main.cpp
#include <unistd.h>
#include <limits.h>
#include <iostream>
using namespace std;
static const int HNAME_MAX = 512;
static const int LNAME_MAX = 512;
int main()
{
char hostname[HNAME_MAX];
char username[LNAME_MAX];
gethostname(hostname, HNAME_MAX);
getlogin_r(username, LNAME_MAX);
cout << "hostname: " << hostname << ", username: " << username << endl;
return 0;
}
Теперь соберем все это дело, запустим и отладим
Нам понадобится скрипт для docker exec
#!/bin/bash
cwd=$(pwd)
docker exec \
-it \
cmaker \
"${@}"
Таска для cmake
{
"label": "cmake initialize scripts (Makefile, Debug)",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"cmake", "-G", "Unix Makefiles",
"-DCMAKE_BUILD_TYPE=Debug",
"-DCMAKE_C_COMPILER=/usr/local/clang_8.0.0/bin/clang",
"-DCMAKE_CXX_COMPILER=/usr/local/clang_8.0.0/bin/clang++",
"/opt"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
Обратите внимание, что тут явным образом указаны пути до компиляторов, это очень важно.
Далее таска для сборки всего проекта и для сборки конкретного
{
"label": "make full project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"make", "-j", "8"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
{
"label": "make (hword) project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"make", "-j", "8", "hword_r"
],
"options": {
"cwd": "${workspaceFolder}"
}
}
И для простого запуска
{
"label": "run (hword) project",
"command": "${workspaceFolder}/scripts/docker_exec.sh",
"args": [
"/opt/build/hword_r"
]
}
С отладкой есть несколько проблем. Во-первых, кроме порта на котором слушает lldb-server надо пробросить еще несколько, на которых будет работать собственно соединение (для этого при запуске контейнера несколько опций -p
). Во-вторых, lldb-server’у этот диапазон портов надо явно задать. И в третьих, надо правильно написать launch.json, выглядит он так:
{
"name": "debug: hword",
"type": "lldb",
"request": "launch",
"program": "/opt/build/hword_r",
"initCommands": [
"platform select remote-linux",
"platform connect connect://127.0.0.1:7000"
],
"cwd": "/opt",
"sourceMap": {
"/opt" : "${workspaceFolder}"
}
}
Жмем F5 и выуля - мы в отладке. Надеюсь это поможет :)
28.11.2018
Небольшая табличка сравнения кода на Swift и кода на Kotlin для справки. Этакий cheet sheet.
Переменные и константы
Swift
let a: Int = 10
var b: Int = 20
Kotlin
val a: Int = 10
var b: Int = 20
Optionals
Swift
let some: Something? = nil
var some: Something? = Something(text: "Hello")
// optional chaining
let greeting = some?.greet()
// elvis
let value = some?.value ?? 30
// optional unwraping
if let some = some {
print(some) // some is not optional
}
// force unwraping
let forced = some!
Kotlin
val some: Something? = nil
var some: Something? = Something("Hello")
// optional chaining
val greeting = some?.greet()
// elvis
val value = some?.value ?: 30
// optional unwraping
some?.let { some -> // it if name is not provided
print(some) // some is not optional
}
if (some != null) {
print(some) // some is smart casted and not optional
}
// force unwraping
val forced = some!!
Форматирование кода
Скобки (круглые и фигурные)
Swift
- Круглые - не обязательны
- Фигурные - обязательны
let str = "Hello, Swift"
func increse(_ value: Int) -> Int {
return value + 1
}
func statements() {
var i = 0
while i < 10 {
i = increase(i)
if str == "something" {
print(str)
}
}
}
Kotlin
- Круглые - обязательны
- Фигурные - не обязательны
val str = "Hello, Kotlin"
fun increase(value: Int): Int = value + 1
fun statements() {
var i = 0
while (i < 10) i = increase(i)
if (str == "something") print(str)
}
Управляющие конструкции
Pattern Matching
Swift
let number = 42
switch number {
case 0...7, 8, 9: print("1 digit")
case 10: print("2 digits")
case 11...99: print("2 digits")
case 100...999: print("3 digits")
default: print("4 or more digits")
}
Kotlin
val number = 42
when (number) {
in 0..7, 8, 9 -> println("1 digit")
10 -> println("2 digits")
in 11..99 -> println("2 digits")
in 100..999 -> println("3 digits")
else -> println("4 or more digits")
}
if, when = expressions, not statements (!)
Swift
Kotlin
val test = if (true) "Test" else "False"
val state = State.Off
fun decide() = when(state) {
State.Off -> "Off"
State.On -> "On"
}
val decision = decide()
Типы
Любой тип
Swift
let a: Any = "Hello"
let b: AnyObject = SomeClass()
Kotlin
val a: Any = "Hello"
// no AnyObject yet
Строковая интерполяция
Swift
let str = "Hello \(nickname)"
let str2 = "Hello \(user.nickname)"
Kotlin
val str = "Hello $nickname"
val str2 = "Hello ${user.nickname}"
Интервалы (Ranges)
Swift
let rng1 = 0...10
let rng2 = 0..<10
Kotlin
val rng1 = 0..10
// no rng2
Коллекции
Swift
let stringArray = [String]()
// no stringList
let stringFloatMap = [String: Float]()
let stringSet = Set<String>()
var countries: [String] = ["Switzerland", "France", "Germany"]
countries.append("Italy")
countries.remove("France")
var jobs: [String: String] = [
"Roger": "CEO",
"Martin": "CTO"
]
jobs["Adrian"] = "Writer"
Kotlin
- Нельзя использовать [] для инициализации массива, используется arrayOf
- в arrayOf добавлять и удалять элементы нельзя, можно только их менять
val stringArray = arrayOf<String>()
val stringList = listOf<String>()
val stringFloatMap = mapOf<String, Float>()
val stringSet = setOf<String>()
val countries = mutableListOf<String>("Switzerland", "France", "Germany")
countries.add("Italy")
countries.remove("France")
val jobs = mutableMapOf(
"Roger" to "CEO",
"Martin" to "CTO"
)
jobs["Adrian"] = "Writer"
Итерирование коллекций
Swift
for str in stringArray {}
for (index, str) in stringArray.enumerated() {}
// no for str in stringList
for (str, num) in stringFloatMap {}
for str in stringSet {}
Kotlin
for (str in stringArray) {}
for (str in stringList) {}
for ((str, num) in stringFloatMap) {}
for (str in stringSet) {}
Процедурное программирование
Процедуры и функции
Swift
func method1(input: String) -> Int {
return input.count
}
// no method2
func method3<T>(input: T) -> String {
return String(describing: input)
}
Kotlin
fun method1(input: String): Int {
return input.length
}
fun method2(input: String) = input.length
fun <T>method3(input: T) = input.toString()
ООП
Интерфейсы
Swift
protocol Person {
var name: String { get set }
func greet() -> String
func showMoreInformation()
}
extension Person {
func greet() -> String {
return "Hello! I am \(self)"
}
}
Kotlin
interface Person {
var name: String
get set
fun greet() = "Hello! I am $this"
fun showMoreInformation()
}
Конструкторы
Swift
class Manager: Person {
var backingName: String
var staff: [Person]
var state: State
let isActive: Bool
init(backingName = "": String, staff: [Person] = [], state: State = .off) {
self.backingName = backingName
self.staff = staff
self.state = state
self.isActive = true
}
Kotlin
class Manager(private var backingName: String = "",
private var staff: MutableList<Person> = mutableListOf<Person>(),
var state: State = State.Off) : Person {
private val isActive: Boolean
init {
isActive = true
}
}
Data Classes (PONSO/POSS vs POJO)
Swift
struct Employee {
let backingName: String
let age: Int
init(backingName: String = "", age: Int = 30) {
self.backingName = backingName
self.age = age
}
}
Kotlin
data class Employee(private var backingName: String = "",
var age: Int = 30) : Person {
Инстанцирование объектов
Swift
let person = Employee(
name: "Olivia",
age: 45
)
Kotlin
val person1 = Employee("Olivia", 45)
val person2 = Employee().apply {
name = "Thomas"
age = 56
}
Расширения классов
Swift
extension Double {
var fahrenheit: Double {
(self * 9 / 5) + 32
}
var celsius: Double {
(self - 32) * 5 / 9
}
}
let temperature = Double(32.0)
let fahrenheit = temperature.fahrenheit
let celsius = fahrenheit.celsius
print("\(temperature) degrees Celsius is \(fahrenheit) degrees Fahrenheit")
Kotlin
val Double.fahrenheit: Double get() = (this * 9 / 5) + 32
val Double.celsius: Double get() = (this - 32) * 5 / 9
val temperature: Double = 32.0
val fahrenheit = temperature.fahrenheit
val celsius = fahrenheit.celsius
println("$temperature degrees Celsius is $fahrenheit degrees Fahrenheit")
Простые объекты и Singleton’ы
Swift
enum Constants {
static let PI = 3.14
static let ANSWER = 42
static func name() -> String {
return "Math constants"
}
}
Kotlin
object Constants {
val PI = 3.14
val ANSWER = 42
fun name() = "Math contstants"
}
Объекты-компаньоны
Swift
- Просто используйте static (class) члены
Kotlin
- В Kotlin нет статических членов
companion object OptionalName {
val MAXIMUM_EMPLOYEE_COUNT = 10
fun managerFactory() = Manager("Maria Hill")
}
Перегрузка операторов
Swift
- Можно делать свои операторы (например квадратный корень)
func + (lhs: Person, rhs: Person) -> Team {
return Team(lhs, rhs)
}
let team = manager + person
Kotlin
operator fun plus(person: Person): Team {
return Team(this, person)
}
val team = manager + person
Инфиксные методы
Swift
Kotlin
infix fun addPerson(person: Person) {
if (staff.count() < MAXIMUM_EMPLOYEE_COUNT) {
staff.add(person)
}
else {
throw Exception("Cannot add more staff members")
}
}
manager addPerson person
Перечисления
Swift
enum State {
case on
case off
var desc: String { ... }
func notify() { ... }
}
Kotlin
enum class State {
On, Off
var desc: String { ... }
func notify() = ...
}
Резюмирующая табличка
|
Kotlin 1.2 |
Objective-C 2.0 |
Swift 4 |
Наследование |
Одинарное, с интерфейсами и расширениями |
Одинарное, с протоколами |
Одинарное, с протоколами и расширениями |
Точки с запятой ; |
Не обязательны |
Обязательны |
Не обязательны |
Объявление класса |
class |
@interface & @implementation |
class |
Интерфейсы |
реализует interface |
поддерживает @protocol |
поддерживает protocol |
Подключение кода |
import (символы) |
#import (файлы) |
import (символы) |
Расширение классов |
Расширения |
Категории |
Расширения |
Динамическая типизация |
Any |
id |
Any |
Суффикс приватных полей |
Не используется |
_ (подчеркивание) |
_ (подчеркивание) |
Управление памятью |
Сборка мусора (GС) |
Ручное или автоматическое управление памятью (MRC vs ARC) |
Автоматическое управление памятью (ARC) |
Дженерики |
да (type erasure) |
да (type erasure) |
да |
Указатели на метод |
нет |
@selector |
#selector |
Обратный вызов (callbacks) |
Лямбды |
Делегаты и блоки |
замыкания |
Указатели |
нет |
да |
Через библиотечные классы |
Корневые классы |
Object |
NSObject / NSProxy / … |
NSObject / NSProxy / … |
Управление областью видимости |
public / internal / protected / private |
@public / @protected / @private (только поля) |
open / public / internal / fileprivate / private |
Обработка ошибок |
try / catch / finally + Exception |
@try / @catch / @finally + NSException |
do / try / catch + Error |
Пространства имен |
Пакеты |
Через префиксы классов |
Неявные, через модули |
Грамматика |
kotlinlang.org |
|
developer.apple.com |