Как использовать паттерн Singleton в iOS. Принципы и подходы
Усі статті, обговорення, новини про Mobile — в одному місці. Підписуйтеся на телеграм-канал!
В мире iOS разработки всё чаще можно услышать про применение различных паттернов проектирования. Особенно это любимая часть вопросов на собеседованиях: «Расскажи какие паттерны используются в стандартной библиотеке CocoaTouch?».
Но при повсеместном и необдуманном использовании паттернов — проекты становятся не только излишне нагроможденными, но и порой даже мешают дальнейшей поддержке и её развитию.
В этой статье я хотел бы отметить плюсы и минусы одного из самых распространенных паттернов — Singleton.
Для начала хотел бы выделить те ресурсы, которые помогут вам лучше освоиться в данной тематике:
- Книга «Приёмы объектно-ориентированного проектирования. Паттерны проектирования» (Эрих Гамма, Ричард Хелм, Ральф Джонсон, Джон Влиссидес) — в простонародье — банда четырех — одна из популярных книг в этом направлении. Книга является справочником, примеры представлены на языке C++ и Smalltalk.
- Книга «Паттерны проектирования» — представлен спектр основных паттернов, которые широко применимы в разработке. Паттерны описываются достаточно подробно, на примерах из реального мира. Код используемый в данной книге представлен на Java, однако все подходы применимы и в других технологиях.
- Книга «Чистая архитектура» (Роберт Мартин) — книга повествующая об основных моментах и подходах встречающихся во множестве программах. Здесь рассказываются основные правила и принципы которые применимы в разработке ПО — ООП, SOLID, SoC и многое другое.
Давайте для начала рассмотрим классификацию паттернов. Как известно, существует три типа паттернов:
- Порождающие — отвечают за удобное создание новых объектов или даже целых семейств объектов.
- Структурные — отвечают за построение удобных в поддержке иерархий классов.
- Поведенческие — решают задачи эффективного взаимодействия между объектами.
Порождающий паттерн 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 здесь выступает отличным примером, так как суть его работы заключается в отправке и в получении сообщений.
Выводы
Несмотря на всю легкость реализации синглтона, его злоупотрбление может обернутся череватыми последствиями, которые будет сложно устранить.
Так же нужно быть внимательным при использовании данного паттерна в многопоточной среде. Необходимо использовать данный подход только когда объект действительно подразумевает своё единственное существование. При реализации старайтесь не нагромождать объект лишним поведением и не задавать ему какие-либо свойства и состояния.
17 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів