Типи не резинові. Про надмірне захоплення Swift Extensions
Іноді я бачу в коді одну річ, і вона мені не подобається. Надто сильна любов до Swift extensions. Ми розширяємо все направо, і наліво.
Давайте почнемо із синтетичного прикладу, а тоді дійдемо до реальних кейсів.
struct Cat {
func sleep()
func eat()
func poo()
func play()
func kernelPanic()
}
Кіт звичайний. Модельний об’єкт нашої апки. Ми його покрили тестами, він весь SOLID до кожної літери.
Пишемо апку. Хтось відповідає за аналітику. Прийшла команда від продактів, що треба логувати скільки разів котик какав (будем мабуть продавати наповнювачі). В модулі аналітики відбувається сплеск ООП думки:
extension Cat {
func propertiesForAnalytics() -> [String: Any] {
[ "Poops Count": self.poopsCountToday() ]
}
}
Працюємо далі. Контора пише UI. Дизайнери кажуть, що треба котиків, які вимазались в тухлій рибі (ваш кіт не гуляв одеськими дворами?) малювати зеленим:
fileprivate extension Cat {
func cellColor() -> UIColor {
if self.fellVictimOfTheViciousFishOdour() {
return .green
}
return .separatorColor
}
}
Все несеться. Працюємо над покращенням first time user experience. Якщо в людини ще нема котика, то показуємо в списку стаби популярних котів з ринку:
extension Cat {
static func makeStub(named: String) -> Cat {
// make a cat and attach a flag with a "pure Swift technology" named associated object
}
func isStub() -> Bool {
// read some stuff attached to the cat with a piece of scotch
}
}
Приклад суто синтетичний, але передає ідею. Ви скажете – ми так не пишемо! Ага, авжеж…
Які я тут бачу проблеми.
- порушення Single Responsibility. Кіт має щось знати про аналітику чи про колір комірки? Кожен скаже – ні! Але чомусь такий код я періодично бачу.
- роздування інтерфейсу об’єкту. Був Cat із п’ятьма методами. Став із 8-ми.
- розмазування інтерфейсу і реалізації об’єкту по всьому проекту. Я нова людина на проекті. Бачу що є Cat. Почитав його файл, і наче все ясно – їсти, спати, срати – все логічно. Але ніт. Далі я раптом дізнаюся, що в кота є ще таємничі здібності логуватися в аналітику, і т.д. Я можу десь знайти повний інтерфейс цього об’єкту?
- рефакторинг. Припустимо, що ми вирішили щось порефакторити в Котах (марна справа, але то таке). Ми зібралися на грумінг, подивилися на Кота, і такі – 2 сторі поінти. Сів хтось працювати, а тут його за сраку і вкусили всі ґорокракси кота, які ми розкидали по проекту. Раптом треба виявляється ще переписати логіку по аналітиці і по стабам! Підісрав котик, га?!
- в цілому схоже, але трохи про інше. Таке розмазування породжує виникнення островів, де продовжують додавати розширення на розширення. А чого – тут вже навалили, чим я гірший? Шкода, що я пишу про котів, а не про бегемотів. І от біля cellColor() виникає avatar(), downloadProfile(), і все це в Cat (а де ж іще?).
- тести. Які тести? Там де той Cat народили може й був ще порив, і там щось покрито. А це ж так – extension… В мене ж задача, в нас же ж реліз. Та що нам буде з одного методу!?
- Щоб extension отримав доступ до тіла, в типі знижуються права доступу, private стає internal, а іноді (всі ж свої) – і до public може дійти.
Перейдем до реальних прикладів Посилання на заголовок
VirtualFile Посилання на заголовок
isTemporaryFile Цей приклад чудовий тим, що він ніколи не потрапив в продакшен.
public extension VirtualFile {
var isTemporaryFile: Bool {
self.fullPath.hasPrefix(TempFileSystem.rootForCurrentSession())
}
}
Давайте поговоримо, чи заслуговує такий метод на місце у публічному інтерфейсі VirtualFile. VirtualFile – це інтерефейс для файлів з будь яких файлових систем. Тобто реально можна прийти до файлу десь на GoogleDrive і спитати в нього,– “ти темповий?”. І що він може на це сказати? По-перше, він навіть не має змоги зробити override, бо isTemporaryFile в extension (можна докрутити на Obj-C, але ж ми так не будемо робити?). По-друге, в контексті GoogleDrive VFS поняття isTemporaryFile взагалі відсутнє, то що нам було б там відповідати – “false”, “ми не знаємо”, “шоб да, так нет”, “I’m a teapot”? Але ж в нас фіча, в нас баг – нам треба знати чи він темповий. То давайте з цього і почнімо – у нас – це десь в конкретному місці, під конкретний випадок треба перевірити конкретний тип файлу, що він лежить в конкретній папці – це аж ніяк не public extension на весь протокол! Пишемо в потрібному коді свою теплу private func, і вона вирішує нашу задачу.
var isSampleFile: Bool {}
Абсолютно те саме. Це суто конкретний випадок конкретної проблеми.
Або візьмем о таке!
extension Error {
var userRejectedAccessToCamera: Bool {
... some digging in NSError ...
}
}
Десь супер локально, fileprivate воно іще все куди не йшло. Але ж люди є люди. Потім в когось буде тисяча причин не хотіти думати, і цей fileprivate стане цілком собі видимий всьому проекту, і гоп! не встигли отямитись, а вже на Error. вам Xcode вивалює список з двадцяти помилок які вам… як мінімум не потрібні. І це ще класно якщо просто не потрібні, а буває що іще так непогано заважають. Ви хотіли Statistics.log(event) написати, а вам автокомпліт підсунув виклик Statistics.log(nicheLibraryEventType), і потім дивишся на це квадратними очима, типу – “Xcode/Swift (підкресли свій варіант) – ти здурів? Як це не можна логувати мій івент? Як не того типу?”.
Collection extensions where … Посилання на заголовок
Ще один частий випадок – extension на Array, який містить певні типи.
extension Array where Element: CGPoint {
func perimeter() {
// якийсь код, який для задачі автора мав порахувати периметр полігону
}
}
Перше, що спадає на думку, а чому це будь-який Array CGPoint-ів – це обов’язково полігон? Це не може бути хмара – “список” ресторанів на мапі? Або базові точки для розпізнавання обличчя?
Друге, якщо ми маємо задачу роботи з полігонами, то давайте в коді так прямо і скажемо. Для початку можемо хоча би так:
typealias Polygon = Array<CGPoint>
extension Polygon {
func perimeter() -> CGFloat {
//
}
}
Це вже краще… Але є проблеми:
- всюди де ми передаємо Polygon можна буде запхати хмару точок, і Свіфт навіть не пікне
- в нашого полігона буде штирчати на зовні все, що йому дісталося від масиву.
- якщо ми захочемо поміняти backing storage або зробити якісь оптимізації, то позбутися Array буде дуже складно
- ну, або як вам таке – для полігону в нас є вимога, що він може бути, а може й не бути (от такі ми анархісти) явно замкнений останній сегмент. Тобто в цьому “generic – всім може згодитися” extension раптово з’являється логіка супер специфічна для нашого конкретного полігону
Тому:
struct Polygon {
private var points: [CGPoint]
func perimeter() -> CGFloat {
//
}
}
О це вже діло. Клієнти будуть знати рівно стільки скільки ми хочемо щоб вони знали. Замість Полігону не просунеш інший масив точок з вулиці. Поміняти Array на MegaFastPointsArray – easy-peasy! Додати кастомний description – є куди! І найголовніше – думаємо “полігон”, пишемо “полігон”, а не “ну це такий масивчик точок”.
Буває, що це ООП виходить на іще вищий рівень, і замість extension Array where Element: CGPoint пишеться extension Collection where Element: CGPoint. Don’t get me started on this one…
То що робити? Посилання на заголовок
Мій головний point не в тому, що extension – це зло, і я проти них в принципі. Ні. Мені найголовніше – це кордони модулів і чистота доменів. Якщо extension додає до об’єкту фунцію в тому ж домені, то я гаряче це підтримую. Візьмемо ті ж CGPoint.
extension CGPoint {
func translated(_ x, CGFloat, _ y: CGFloat) -> CGPoint { .. }
}
Ну шикарна ж функція!
Але якщо (дуже висмоктане з пальцю):
extension CGPoint {
func isInsideView(_ view: UIView) -> Bool {}
}
От тут я вже засмучуюсь. Домен UIView знаходиться над доменом CoreGraphics. Ми раптом в світ CGPoint закинули якесь НЛО. isInsideView це проблема домену UIKit, і вирішуватись має в домені UIKit:
extension UIView {
func containsPoint(_ point: CGPoint) -> Bool {}
}
Колись було модно всюди використовувати наслідування, пам’ятаєте? Як на мене зараз щось подібне відбувається з extension-ами. Пропоную думати про extension не як про щось таке “я тут збоку навалю – ніхто не помітить”, а як про частину публічного API типу. Якщо цей extension перемістити до самого об’єкту – воно буде там доречне? Якщо ні, то й extension не треба писати. Інакше ми робимо з нашого коду о таке:
