Как использовать паттерн Singleton в iOS. Принципы и подходы

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті.

В мире iOS разработки всё чаще можно услышать про применение различных паттернов проектирования. Особенно это любимая часть вопросов на собеседованиях: «Расскажи какие паттерны используются в стандартной библиотеке CocoaTouch?».

Но при повсеместном и необдуманном использовании паттернов — проекты становятся не только излишне нагроможденными, но и порой даже мешают дальнейшей поддержке и её развитию.

В этой статье я хотел бы отметить плюсы и минусы одного из самых распространенных паттернов — Singleton.

Для начала хотел бы выделить те ресурсы, которые помогут вам лучше освоиться в данной тематике:

  • Книга «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (Эрих Гамма, Ричард Хелм, Ральф Джонсон, Джон Влиссидес) — в простонародье — банда четырех — одна из популярных книг в этом направлении. Книга является справочником, примеры представлены на языке C++ и Smalltalk.
  • Книга «Паттерны проектирования» — представлен спектр основных паттернов, которые широко применимы в разработке. Паттерны описываются достаточно подробно, на примерах из реального мира. Код используемый в данной книге представлен на Java, однако все подходы применимы и в других технологиях.
  • Книга «Чистая архитектура» (Роберт Мартин) — книга повествующая об основных моментах и подходах встречающихся во множестве программах. Здесь рассказываются основные правила и принципы которые применимы в разработке ПО — ООП, SOLID, SoC и многое другое.

Давайте для начала рассмотрим классификацию паттернов. Как известно, существует три типа паттернов:

  1. Порождающие — отвечают за удобное создание новых объектов или даже целых семейств объектов.
  2. Структурные — отвечают за построение удобных в поддержке иерархий классов.
  3. Поведенческие — решают задачи эффективного взаимодействия между объектами.

Порождающий паттерн Singleton — всеми любимый и всеми ненавистный. Сколько копий было сломано и сколько заточено. Кто-то его боготворит, а другие называют его анти-паттерном. Почему же данный паттерн привлекает к себе столько внимания? Стоит отметить что в стандартной iOS библиотеке данный паттерн попадается с завидной регулярностью в следующих классах:

  • UserDefaults
  • URLSession
  • NotificationCenter
  • Bundle

И это не полный список.

Давайте для начала дадим формальное определение этому паттерну:

«Порождающий шаблон проектирования, гарантирующий, что в однопоточном приложении будет единственный экземпляр некоторого класса, и предоставляющий глобальную точку доступа к этому экземпляру» © Wiki

Что это всё значит? Наверняка многие из вас уже использовали хотя бы один класс представленный в списке выше. Так вот суть заключается в том, что они не подразумевают наличие или создание дополнительных экземпляров с помощью стандартных конструкторов. Нет, конечно у вас есть возможность создать экземпляр класса NotificationCenter() но это не будет иметь какого-либо смысла. Получение и отправка в данном случае не будут регистрироваться где-либо помимо скоупа данного экземпляра.

Стандартный механизм определения Singleton выглядит следующим образом:

import Foundation
public final class User {
    static let `default` = User()
    private init() {}
    private var _posts = Set<String>()
    func add(post: String) {
        _posts.insert(post)
    }
    func remove(post: String) {
        _posts.remove(post)
    }
    func allPosts() -> [String] {
        Array(_posts)
    }
}

Основной фокус заключается в объявление статического константного stored свойства default. Подход аналогичен другим языкам, поэтому здесь ничего нового. Теперь, при обращении к свойству default мы будем иметь дело с одним и тем же экземпляром. Хорошей практикой также будет сделать конструктор приватным, чтобы не давать возможность создавать сторонние экземпляры данного класса из других компонентов приложения. Далее следует стандартный механизм объявления свойства с дополнительными модификаторами доступа.

Давайте теперь дополним наше свойство posts новым значением

Класс AppDelegate.swift:

 func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
    print(User.default.allPosts())
    User.default.add(post: "New post")
    print(User.default.allPosts())
    return true
  }
  Output:
  []
  ["New post"]

А теперь представим, что наша программа работает в многопоточной среде и выполняется следующий flow:


Thread 1 Thread 2
AppDelegate.swift UserAuthorizationService.swift
... ...
User.default.addPost("New post") User.default.addPost("Old post")
print(User.default.allPosts()) ...

Какой результат отобразится в консоли при выполнении print(User.default.allPosts()) ?

Неопределено. Это может быть как [«New post»] так и [«Old post»]. Или программа вовсе может завершить свою работу аварийно. Это один из недостатков данного паттерна, он заключается в необходимости поддерживать безопасное и синхронизированное выполнение кода. Стоит отметить, что даже если класс User не был бы синглтоном, поведение так же оставалось бы неопределённым, но в таком случае это легче отследить и исправить нежели в случае паттерна «Одиночка» который вносит единое глобальное состояние в нашу программу.

Давайте проверим, как выполняется наш код в многопоточной среде. Для этого нам нужно будет создать следующий тест кейс:

    func testConcurrencyAccess() {
        let concurentQueue = DispatchQueue(label: "test.concurent.queue", attributes: .concurrent)
        let expect = expectation(description: "Multi threading test")
        for iteration in 0...100 {
            concurentQueue.async {
                User.default.add(post: "Post #\(iteration)")
            }
            concurentQueue.async {
                User.default.remove(post: "Post #\(iteration)")
            }
        }
        expect.fulfill()
        waitForExpectations(timeout: 5.0) { error in
            XCTAssertNil(error, "Concurrency failed")
        }
    }

Если запустить этот тест — мы получаем краш:

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

Для решения этой проблемы существует множество разных способов: NSLock’и, DispatchQueue или более низкоуровневые решения, такие как mutex или семафоры

Я рекомендую использовать более высокоуровневые подходы, поэтому воспользуемся классом NSLock.

import Foundation
public final class User {
    static let `default` = User()
    private init() {}
    private var _posts = Set<String>()
    private let synchronizationLock = NSLock()
    func add(post: String) {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        _posts.insert(post)
    }
    func remove(post: String) {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        _posts.remove(post)
    }
    func allPosts() -> [String] {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        return Array(_posts)
    }
}

Здесь мы создаем приватное константное свойство synchronizationLock которое и обеспечивает синхронизированный доступ к объектам posts. Давайте сново попробуем запустить наш testConcurrencyAccess().

Отлично! Это как раз то что нам нужно.

Еще одним из недостатков которым относят к использованию Singleton это усложненное использование его в тестах. Давайте для начала приведем наш класс User к следующему виду:

import Foundation
public final class User {
    static let `default` = User()
    private init() {}
    private var _posts = Set<String>()
    private let synchronizationLock = NSLock()
    public var isAdmin = false
    private var name = ""
    func add(post: String) {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        _posts.insert(post)
    }
    func remove(post: String) {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        _posts.remove(post)
    }
    func allPosts() -> [String] {
        synchronizationLock.lock()
        defer { synchronizationLock.unlock() }
        return Array(_posts)
    }
    func set(name: String) {
        self.name = name
    }
    func userName() -> String {
        var currentName = name
        if isAdmin {
            currentName = "SUPER_USER"
        }
        return currentName
    }
}

Далее создадим следующий тест кейс:

import XCTest
class UserTests: XCTestCase {
    func testAdminUserName() {
        let expectedName = "SUPER_USER"
        User.default.isAdmin = true
        let username = User.default.userName()
        XCTAssertEqual(expectedName, username)
    }
    func testUserName() {
        let expectedName = "Peter"
        User.default.set(name: "Peter")
        let username = User.default.userName()
        XCTAssertEqual(expectedName, username)
    }
}

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

Что же здесь произошло? Первый тест testAdminUserName() выполнился успешно, задав нашему синглтон объекту значение isAdmin — true и в итоге ожидаемое имя совпадает с фактическим — SUPER_USER. Далее тестовый suit переходит ко второму тесту testUserName() и здесь происходит обращение к тому же самому экземпляру объекта, который использовался в первом тесте. А это значит что значение свойства isAdmin всё так же имеет значение true, поэтому метод userName() возвращает нам SUPER_USER вместо ожидаемого «Peter». В этом и заключается неочевидность использования синглтонов в тестах.

Чтобы избежать подобного поведения можно добавить возможность обнуления значений свойств в классе User и вызывать этот метод после каждого теста.

import Foundation
public final class User {
    static let `default` = User()
    private init() {}
    private var _posts = Set<String>()
    private let synchronizationLock = NSLock()
    public var isAdmin = false
    private var name = ""
    func cleanUp() {
        isAdmin = false
        name = ""
        _posts = Set<String>()
    }
    ...
}

Теперь исправим наши тесты следующим образом:

import XCTest
class UserTests: XCTestCase {
    func testAdminUserName() {
        let expectedName = "SUPER_USER"
        User.default.isAdmin = true
        let username = User.default.userName()
        XCTAssertEqual(expectedName, username)
        User.default.cleanUp()
    }
    func testUserName() {
        let expectedName = "Peter"
        User.default.set(name: "Peter")
        let username = User.default.userName()
        XCTAssertEqual(expectedName, username)
        User.default.cleanUp()
    }
}

После каждого выполненного тест кейса нам необходимо обнулить состояние пользователя и только в таком случае наши тесты пройдут. К слову, использование метода User.default.cleanUp() в override методах setUp() или tearDown() не подойдут, так как эти методы вызываются в начале и в конце всех запущенных тестов. Согласитесь, что данный подход вносит мало ясности в происходящее и может только запутать других разработчиков. Ко всему прочему, подобные проблемы могут возникать не только в тестах но и в основном таргете приложения.

Плюсы

После того как мы рассмотрели минусы данного паттерна, давайте всё-таки обозначим его плюсы.

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

Также данный паттерн применим к тем классам которые не имеют какого-либо состояния, для того, чтобы мы могли избежать неопределенных результатов. NotificationCenter здесь выступает отличным примером, так как суть его работы заключается в отправке и в получении сообщений.

Выводы

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

Так же нужно быть внимательным при использовании данного паттерна в многопоточной среде. Необходимо использовать данный подход только когда объект действительно подразумевает своё единственное существование. При реализации старайтесь не нагромождать объект лишним поведением и не задавать ему какие-либо свойства и состояния.

👍НравитсяПонравилось8
В избранноеВ избранном2
LinkedIn
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Допустимые теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
UserDefaults
URLSession
NotificationCenter
Bundle

Эти объекты не используют Singleton как вы описали, они часто предоставляют фабричный метод `default`, это больше даже похоже на «Flyweight pattern» чем на Singleton.

Foundation не запрещает создавать различные объекты данных типов, так как они могут работать с разными сущностями, ресурсами и поведением.

всеми любимы и всеми ненавистный

Одно «й» потерял

По теме: от синглтонов сложно уйти когда идет работа с «эксклюзивными» ресурсами. В клиентском приложении часто есть смысл их избегать

С использованием синглтонов — есть 1 очень простой принцип: не использовать. Т.к. синглтон это не паттерн, а анти-паттерн.

Ждём антипруфов. Технически да, злоупотреблять им не следует, пихая где попало. Но паттерн востребован, без него никуда. Даже если он только иллюзия.

Если синглтоны в проекте не начали мешать — значит, это проекты размера приложения «хелло-уорлд».

Если плохому танцору не начали мешать...

Синглтоны, будучи инстансами с глобальной видимостью — нарушают все принципы инкапсуляции, превращая зависящий от них код в «спагетти», с проблемами при 1) реиспользовании 2) тестировании. Достаточно, чтобы не иметь их в коде.

Но как для мелких мобильных проектов, в 1к строк кода, да под пивко — покатят. :)

Синглтон не обязательно имеет глобальную видимость. Вся его суть — ленивая инициализация, и никакой другой роли он не играет. Более сложные паттерны умеют связывать его с источником, и подглядывая за поведением, могут пересобрать синглтон, если старый по какой-то причине сдох.

Грубо говоря, Синглтон не более чем частный случай кеширования ресурса. Со всеми вытекающими из этого и сложностями синхронизации, и проблемами поддержания жизненного цикла.

А кеширование сложно назвать антипаттерном. Хотя ты абсолютно прав, считая что оно нарушает правила как инкапсуляции, так и нормальность форм хранения данных. Это избыточность по сути, которая даёт скорость ценой дублирования и надёжность ценой скорости.

Мораль: за 5 лет меняется всё, за 100 лет — ничего.

Ленивая инициализация и кэширование — это вообще о другом. При чём тут (анти-)паттерн «синглтон»?

По природе информации — «это другое» (нет). Суть — в нарушении нормализации, в создании избыточности и получении скорости — а потом создании механизма контроля, чтобы не допустить нарушения целостности данных.

Собственно говоря, реализации синглтона сводятся к контролю целостности и попытке выжать скорость.

реализации синглтона сводятся к контролю целостности и попытке выжать скорость.

Суть паттерна — 1) публичный неограниченный доступ (что есть фу-фу) 2) к единственному инстансу с некими данными (что есть, в приципе, неплохо).

Hичего другого там нет (целостности/скорости нет тоже) — и недостатки «фу-фу» перевешивают.

Это паттерн, а не реализация. Как хочешь, так и реализуй. Не нравится публичный доступ — делай какой захочешь, паттерн всё равно останется прежним. Не нравится единственный доступ — добавь метод newInstance и пусть он тебе делает новых деток по твоим правилам.

Грубо говоря, делай то что тебе надо и на х&u [это тоже паттерн] верти эти все «лучшие практики» на 20 лет устаревшие.

Паттернами есть всё, что ты можешь объяснить словами. И каждое слово есть паттерн. Потому онанировать на творчество бюрократов, возведших чтиво под чашечку виски до святого писания — надо быть такими же безголовыми болванами. Кем надо быть чтобы платить им деньги — науке это неизвестно.

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

По сути Синглтон является кешем на 1 сущность. Вот и весь сказ. А кеши соответственно держат под собой карту синглтон-объектов с общим оркестратором. Но бюрократы их так не называют, потому что вынепонимаете этодругое!

Это паттерн, а не реализация. Как хочешь, так и реализуй. Не нравится публичный доступ — делай какой захочешь, паттерн всё равно останется прежним. Не нравится единственный доступ — добавь метод newInstance и пусть он тебе делает новых деток по твоим правилам.

Ещё раз для тугодогоходящих, паттерн характеризуется:
— единственностью инстанса
— глобальной видимостью/публичностью

Это суть паттерна, Какие-ещё «Как хочешь, так и реализуй. »? Реализуешь по-другому — это будет не синглтон.

П.С.И при чём тут «кэш»? Кэш это совсем о другом.

Если объект — синглтон, остальным частям приложения внезапно не обязательно знать об этом, другие классы могут получать зависимость через конструктор/аргументы метода так же как в случае с не-синглтонами.

другие классы могут получать зависимость через конструктор/аргументы метода

Могут. Но, как правило, всё что доступно для использования — будет использовано. И чем кода и писателей больше — тем чаще будет использоваться.

А в итоге, нетестируемое «спагетти»...

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

Сокращу до смысла:

Обратная сторона данной монеты заключается в том, что приходится расплачиваться производительностью, но с современными устройствами это не кажется уже такой критичной проблемой.

Эта «истина» звучит со времён даже не IT, а рабовладельчества. Да, всё через жопу, рабы сдохнут, но бабы ж ещё нарожают.

Охудивительны тесты, где ради того чтобы изменить тест, меняют исходную логику кода, создавая новый код, который удобно будет тестить, при этом место где тест падал просто закостылили.

Если ради сраного синглтона приходится писать такой талмуд, не логичным было бы предположить, что 10 из 10 джунов нихрена не поймут и наворотят ещё большего говнокода? Почему бы вместо многабукав просто не написать краткий и понятный код, который годится для копипасты?

Подписаться на комментарии