Код
#статьи

Что такое Singleton и как его использовать в разработке приложений

Знакомимся с одним из самых популярных и спорных паттернов проектирования, его реализациями и альтернативой.

Иллюстрация: Катя Павловская для Skillbox Media

Паттерны проектирования — это лучшие практики написания кода, которые помогают разработчикам не изобретать велосипеды, а писать программы по проверенным рецептам. Многие из них впервые были описаны в книге «Паттерны объектно-ориентированного проектирования» Эриха Гамма, Ричарда Хелма, Ральфа Джонсона и Джона Влиссидеса.

Среди этих паттернов есть свой Гамлет — такой же противоречивый и непредсказуемый. Речь о Singleton, шаблоне проектирования «одиночка». Сегодня вы узнаете:


Что такое Singleton

Singleton (с англ. «одиночка») — это паттерн проектирования, гарантирующий, что у класса будет только один экземпляр. К этому экземпляру будет предоставлена глобальная, то есть доступная из любой части программы, точка доступа. Если попытаться создать новый объект этого класса, то вернётся уже созданный существующий экземпляр.

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

Другой пример — менеджер паролей. Он создаёт только один объект для каждой учётной записи и возвращает его при запросе — для одной учётной записи не может быть двух разных паролей.

Где применяют шаблон проектирования Singleton

Конфигурационные настройки. Представим, что у нас есть класс с настройками приложения — параметрами базы данных или внешнего вида интерфейса. Имеет смысл реализовать его как Singleton. Это обеспечит одну точку доступа к настройкам, и весь код сможет ссылаться на одни и те же настройки.

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

Логирование. Singleton удобно использовать для логов. Вместо создания нового логгера каждый раз, когда нужно что-то залогировать, мы записываем всё в один объект.

Счётчики и глобальные объекты. Паттерн «одиночка» подходит для оценки состояния приложения или сбора статистики с его модулей. С классом можно будет взаимодействовать из любой части программы.

Пул ресурсов. Если у нас ограниченный пул соединений к внешнему сервису или к другим ресурсам, то Singleton гарантирует, что доступ к ним всегда будет идти через единственный экземпляр.

Как работает Singleton

Классическая реализация шаблона «одиночка» — конструктор и метод getInstance(). В коде на языках программирования, использующих объектно-ориентированный подход, паттерн выглядит примерно одинаково:

Python

class Singleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(Singleton, cls).__new__(cls)
        return cls._instance

    def __init__(self):
        # Этот конструктор вызывается только при первом создании экземпляра
        pass

# Использование
singleton1 = Singleton()
singleton2 = Singleton()

print(singleton1 is singleton2)  # Выведет: True, так как это один и тот же экземпляр

Java

public class Singleton {
    private static Singleton instance;

    private Singleton() {
        // Приватный конструктор
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

// Использование
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();

System.out.println(singleton1 == singleton2);  // Выведет: True, так как это один и тот же экземпляр

Пока что код в примерах выглядит сложно, но мы разберём его дальше. Чтобы было понятнее, рассмотрим базовый шаблон Singleton:

class Singleton:
    _instance = None  # Приватное поле для хранения единственного экземпляра класса. Пока здесь None, то есть ничего

    def __new__(cls):
        if cls._instance is None:  # Если экземпляр ещё не создан
            cls._instance = super(Singleton, cls).__new__(cls)  # Создаём экземпляр с помощью приватного конструктора __new__
        return cls._instance  # Возвращаем или созданный ранее, или существующий экземпляр

Здесь всё просто. Создаём экземпляр в том случае, если он ещё не создан. Приватный конструктор __new__ гарантирует, что экземпляр класса можно создать только внутри самого класса. Возвращаем либо созданный ранее экземпляр, либо тот, что создали только что.

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

Разберём эти понятия на простом примере. Представьте, что вам необходимо приготовить какое-то блюдо, например пирожки с картофелем. Чтобы получить конечный результат — готовый пирожок, нужно приготовить тесто и начинку. Быстрее будет разделить задачу на двоих — один замешивает тесто, а второй — толчёт картофель, добавляет туда соль, лук и так далее. Так мы реализуем многопоточность, то есть одновременное выполнение нескольких задач.

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

Проблемы Singleton

Паттерн ругают за то, что он доставляет больше проблем, чем решает. Такая критика иногда обоснованна. Вот основные проблемы при его использовании:

Глобальное состояние. Singleton доступен из любой части программы, то есть сломать его может кто угодно. Разберём на примере.

Синглтон GameManager управляет состоянием игры:

class GameManager:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(GameManager, cls).__new__(cls)
            cls._instance.state = "initialized"
        return cls._instance

# Использование
game_manager1 = GameManager()
game_manager2 = GameManager()

game_manager1.state = "running"

print(game_manager2.state)  # Выведет: "running", так как state — общее для всех экземпляров

Здесь мы собрали синглтон по шаблону, и добавили ему описание состояния cls._instance.state = «initialized», которое потом поменяли на «running» в одной переменной. Но так как переменные ссылаются на один и тот же объект, изменения коснутся их всех.

Потокобезопасность. Базовая реализация паттерна «одиночка» непотокобезопасна. Если не предусмотрены механизмы синхронизации, разные потоки могут наштамповать кучу синглтонов.

import threading

class ThreadUnsafeSingleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(ThreadUnsafeSingleton, cls).__new__(cls)
        return cls._instance

def worker():
    singleton = ThreadUnsafeSingleton()
    print(singleton)

# Запуск нескольких потоков
threads = []
for _ in range(5):
    thread = threading.Thread(target=worker)
    threads.append(thread)
    thread.start()

# Вывод: может быть несколько разных экземпляров ThreadUnsafeSingleton

Здесь в одном из потоков условие cls._instance is None выполняется, и экземпляр создаётся. Но до того, как он будет присвоен переменной _instance, другие потоки могут также пройти через это условие и создать свои экземпляры. И наш Singleton уже будет не single.

Тестирование. Для тестирования класса с Singleton потребуются мок-объекты. А заменить реальный объект на мок не всегда легко или вообще возможно.

Рассмотрим DatabaseConnection — синглтон, который устанавливает и поддерживает соединение с базой данных. Тестировать код, зависящий от него, сложно — в тестах придётся подменить все соединения на мок-объекты:

class DatabaseConnection:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(DatabaseConnection, cls).__new__(cls)
            cls._instance.connect()
        return cls._instance

    def connect(self):
        print("Connected to the database")

class UserDatabaseService:
    def __init__(self):
        self.db_connection = DatabaseConnection()

    def create_user(self, username):
        # Создание пользователя в базе данных
        print(f"User '{username}' created")

# Теперь представим, что мы хотим протестировать UserDatabaseService
user_service = UserDatabaseService()

# Проблема: мы не можем легко подменить DatabaseConnection на мок-объект для тестов

Здесь мы создали экземпляр класса UserDatabaseService через конструктор класса DatabaseConnection, который является синглтоном. Чтобы покрыть тестами UserDatabaseService, нам придётся лезть в DatabaseConnection и вручную подгонять его под тестовые условия.

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

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

Вот пример того, как не надо прописывать класс:

class Logger:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(Logger, cls).__new__(cls)
        return cls._instance

    def log(self, message):
        print("Logging:", message)

class UserManager:
    def __init__(self):
        self.logger = Logger()  # Зависимость от Singleton

    def create_user(self, username):
        self.logger.log(f"User '{username}' created")

user_manager = UserManager()
user_manager.create_user("john_doe")

Здесь UserManager отвечает за создание пользователей и в то же время создаёт и использует логгер. Это усложняет код. Лучше разделить эти две функции между отдельными классами:

class Logger:
    def log(self, message):
        print("Logging:", message)

class UserManager:
    def __init__(self):
        self.logger = Logger()

    def create_user(self, username):
        self.logger.log(f"User '{username}' created")

class Database:
    def save_user(self, user_data):
        print("Saving user to the database:", user_data)

class UserService:
    def __init__(self):
        self.user_manager = UserManager()
        self.database = Database()

    def create_user(self, username):
        self.user_manager.create_user(username)
        user_data = {"username": username}
        self.database.save_user(user_data)

# Использование
user_service = UserService()
user_service.create_user("john_doe")

В этом примере мы разделили функции UserManager на два отдельных класса: UserManager и Database. UserManager теперь отвечает только за создание пользователей и использование логгера, а Database занимается сохранением данных о пользователях в базу данных.

Затем мы создали новый класс UserService, который объединяет функциональность UserManager и Database для удобства использования. Теперь каждый класс имеет свою чёткую ответственность, что делает код более понятным и удобным для изменения.

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

Реализации шаблона Singleton

Мы должны прописывать синглтон так, чтобы он был потокобезопасным, ленивым и многопоточным. Разберёмся в каждом термине и посмотрим на примеры кода.

Потокобезопасный

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

import threading

class ThreadSafeSingleton:
    _instance = None
    _lock = threading.Lock()  # Мьютекс для синхронизации

    def __new__(cls):
        with cls._lock:
            if cls._instance is None:
                cls._instance = super(ThreadSafeSingleton, cls).__new__(cls)
            return cls._instance

# Использование
singleton1 = ThreadSafeSingleton()
singleton2 = ThreadSafeSingleton()

print(singleton1 is singleton2)  # Выведет: True, так как это один и тот же экземпляр

Здесь мы использовали функцию threading.Lock(), чтобы показать другим потокам, что они пока не могут создавать собственные синглтоны.

Ленивый

Ленивый Singleton создаёт экземпляр только при первом обращении к нему. Это позволяет отложить его создание до тех пор, пока он действительно не потребуется.

class LazySingleton:
    _instance = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super(LazySingleton, cls).__new__(cls)
        return cls._instance

# Использование
singleton1 = LazySingleton()
singleton2 = LazySingleton()

print(singleton1 is singleton2)  # Выведет: True, так как это один и тот же экземпляр

Эта реализация очень напоминает наш шаблон. Всё потому, что создать неленивый синглтон на Python невозможно — этот язык не поддерживает создание экземпляров класса без обращения к нему, то есть синглтоны здесь ленивые по умолчанию. Поэтому посмотрим на Java:

public class NonLazySingleton {
    private static final NonLazySingleton instance = new NonLazySingleton();

    // Приватный конструктор, чтобы предотвратить создание экземпляров извне
    private NonLazySingleton() {}

    // Метод для получения единственного экземпляра
    public static NonLazySingleton getInstance() {
        return instance;
    }
}

Здесь мы прописали в статическом поле класса ключевое слово final, и тем самым сделали переменную instance константой и там же создали экземпляр. А так как в Java статические поля исполняются при загрузке, обратимся мы к классу или нет — неважно, наш синглтон уже создан.

Многопоточный

Многопоточный Singleton обеспечивает безопасное создание экземпляра в многопоточной среде. Этот вариант использует двойную проверку для оптимизации создания:

import threading

class MultithreadedSingleton:
    _instance = None
    _lock = threading.Lock()

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super(MultithreadedSingleton, cls).__new__(cls)
        return cls._instance

# Использование
singleton1 = MultithreadedSingleton()
singleton2 = MultithreadedSingleton()

print(singleton1 is singleton2)  # Выведет: True, так как это один и тот же экземпляр

Здесь уже знакомый нам threading.Lock() гарантирует, что в каждый определённый момент времени у нас будет только один экземпляр MultithreadedSingleton. А благодаря особенностям Python наш синглтон ещё и ленивый.

Последняя реализация паттерна отлично подходит для решения задач. Но минусы есть и у неё:

Костыли. В реализации синглтона мы прямо описываем механизм ленивой инициализации и блокировки. Класс становится сложнее для понимания и для отладки по сравнению с предыдущими вариантами.

Скрытые зависимости. Наш синглтон зависит от правильной работы threading.Lock(). Если это неявное требование не будет чётко задокументировано, другие разработчики могут проигнорировать его и столкнуться с непредвиденными проблемами.

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

Антипаттерн «подделки». Синглтон реализуется через наследование от super().__new__(cls), а множественное наследование может вызвать неожиданные результаты. Если кто-нибудь пропишет ваш синглтон в качестве родительского класса, то сможет использовать его методы, но может запутаться, если у другого родителя будут одноимённые методы.

Этих проблем можно избежать, если вместо синглтона использовать статический класс.

Singleton или статический класс

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

Преимущества статического класса

Простота. Статический класс более понятен и прозрачен, когда дело касается создания экземпляра. Методы и переменные используются напрямую, без getInstance().

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

Вот наглядный пример того, как статический класс упрощает код:

public class AppSettings {
    public static String API_KEY = "default_key";
    public static String BASE_URL = "https://api.example.com";
}

// Использование
System.out.println(AppSettings.API_KEY);  // Выведет: default_key
System.out.println(AppSettings.BASE_URL);  // Выведет: https://api.example.com

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

А вот как выглядит код с той же функциональностью, если завернуть его в Singleton:

public class AppConfig {
    private static AppConfig instance = new AppConfig();
    private String apiKey = "default_key";
    private String baseUrl = "https://api.example.com";

    private AppConfig() {
        // Приватный конструктор
    }

    public static AppConfig getInstance() {
        return instance;
    }

    public String getApiKey() {
        return apiKey;
    }

    public void setApiKey(String apiKey) {
        this.apiKey = apiKey;
    }

    public String getBaseUrl() {
        return baseUrl;
    }

    public void setBaseUrl(String baseUrl) {
        this.baseUrl = baseUrl;
    }
}

// Использование
AppConfig config = AppConfig.getInstance();
System.out.println(config.getApiKey());  // Выведет: default_key
System.out.println(config.getBaseUrl());  // Выведет: https://api.example.com

// Изменение настроек через синглтон
config.setApiKey("new_key");
config.setBaseUrl("https://new-api.example.com");

// Проверка изменённых настроек
System.out.println(config.getApiKey());  // Выведет: new_key
System.out.println(config.getBaseUrl());  // Выведет: https://new-api.example.com

Нужно прописать конструктор и создать экземпляр, если мы хотим вытащить какое-то значение.

Недостатки статического класса

Код со статическим классом гораздо короче и понятнее. Но он — не панацея. У него есть свои недостатки.

Статический класс предоставляет меньше гибкости для настройки экземпляра. Синглтон может включать нестатические методы и переменные. Это позволяет динамично взаимодействовать с экземпляром.

Подход со статическим классом может не во всех языках программирования работать так же, как в Java или C++. Реализация часто меняется в зависимости от контекста и возможностей языка.

Когда выбирать Singleton, а когда — статический класс

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

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

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

Использование настроек и состояний. Для класса с состояниями или настройками, которые должны меняться динамически, выбирайте синглтон.

Статический класс используем в двух случаях:

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

Если класс не должен иметь изменяемого состояния и должен быть независим от конкретных экземпляров.

В конечном счёте выбор зависит от требований вашего проекта, гибкости, которую вы хотите иметь, и особенностей языка программирования, на котором вы работаете.

Больше интересного про код — в нашем телеграм-канале. Подписывайтесь!

Проверьте свой английский. Бесплатно ➞
Нескучные задания: small talk, поиск выдуманных слов — и не только. Подробный фидбэк от преподавателя + персональный план по повышению уровня.
Пройти тест
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована