Singleton и Dependency Injection
Накратко: Singleton гарантира един-единствен обект от клас, а Dependency Injection подава зависимостите отвън, затова кодът се тества с подменена зависимост, например часовник.
Проблем
Тестът, който минава само в петък
В петък магазинът дава 10% отстъпка. Часовникът на програмата е един, затова е Singleton: клас, от който има само един обект, достъпен отвсякъде. Услугата за поръчки го пита кой ден е:
class SystemClock: def today(self): return date.today()
clock = SystemClock()
class OrderService: def price(self, amount): if clock.today().weekday() == FRIDAY: return amount * 0.9 return amountНякой пише тест, за да е сигурен, че отстъпката работи:
service = OrderService()print(f"Петъчна отстъпка: очаквам 90.00 €, получих {service.price(100):.2f} €")Пуснат в понеделник, 26 октомври 2026, тестът печата:
Петъчна отстъпка: очаквам 90.00 €, получих 100.00 €В петък същият тест печата 90.00. Тестът минава един ден в седмицата и пада в останалите шест, а кодът е същият. Да провериш отстъпката значи да чакаш петък или да местиш часовника на компютъра.
Часовникът сам по себе си е направен правилно. Проблемът е, че price сам посяга към него. Отвън не се вижда, че услугата зависи от часовник: OrderService() не иска нищо. Тестът не може да ѝ даде друг ден, защото не може да ѝ даде нищо.
Идеята
Единственият обект и подаването отвън
Singleton (единак) пази две неща: обектът е само един, и всеки може да го вземе отвсякъде. Първото понякога е нужно. Второто е цената:
- скрита зависимост. Кой ползва часовника, личи само ако прочетеш всеки ред от всеки метод;
- общо състояние. Каквото един сменя в обекта, вижда всеки друг, включително следващият тест;
- не се сменя. Кодът сочи конкретния клас по име. Друг часовник за теста няма откъде да влезе.
Поправката не е да махнеш часовника, а да го подадеш отвън. Услугата не търси зависимостта си, а я получава в конструктора. Кой часовник ще е, решава този, който създава услугата: програмата подава истинския, а тестът свой, с дата, която той избира.
Това се казва Dependency Injection (подаване на зависимостите, DI). Ролите са три:
- услугата казва какво ѝ трябва: параметър в конструктора. Какъв е обектът, не я интересува, стига да има метод
today(); - зависимостта е обектът, който ѝ се дава: истинският часовник или тестовият;
- сглобяването става на едно място, в началото на програмата. Там се решава кой обект при кого отива.
Singleton може и да остане: единственият часовник се подава като всеки друг обект. Просто никой не посяга към него сам.
Виж го
Singleton, както го дава езикът
clock.py) се изпълнява веднъж, при първото import. Всяко следващо from clock import clock дава същия обект. Обект на най-горно ниво в модула е Singleton без нито ред допълнителен код:class SystemClock: def __init__(self): print("Създавам часовника")
def today(self): return date.today()
clock = SystemClock()print("Старт")Създавам часовникаСтартПо-точно, за любопитните
__new__ от „Шаблони за дизайн“ е нужен рядко. Ако все пак правиш клас, който пази един обект, помни, че __init__ се вика при всяко създаване, дори когато __new__ връща стария обект.Виж го
Часовникът идва отвън
today(). Модулът с clock = SystemClock() остава същият:class OrderService: def __init__(self, clock): self.clock = clock
def price(self, amount): if self.clock.today().weekday() == FRIDAY: return amount * 0.9 return amountВ програмата истинският часовник се подава веднъж, при създаването:
service = OrderService(clock)Сега OrderService(...) казва на глас, че без часовник не може. Зависимостта се вижда в подписа на конструктора, а не някъде вътре в метод.
Виж го
Тестът сам избира деня
За теста е нужен часовник, на който казваш коя дата да връща:
class FakeClock: def __init__(self, day): self.day = day
def today(self): return self.dayТестът подава него вместо истинския:
clock = FakeClock(date(2026, 10, 30))service = OrderService(clock)print(f"Петък: {service.price(100):.2f} €")
clock.day = date(2026, 10, 27)print(f"Вторник: {service.price(100):.2f} €")Петък: 90.00 €Вторник: 100.00 €clock = FakeClock(date(2026, 10, 30))service = OrderService(clock)print(f"Петък: {service.price(100):.2f} €")
clock.day = date(2026, 10, 27)print(f"Вторник: {service.price(100):.2f} €")Стъпка по стъпка, с думи
- Преди price сам посягаше към clock на ниво модул: скрита връзка към глобален обект, която тестът не може да смени.
- Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
- Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
- price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
- Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
- Същото price(100): вторник, 100.00. Тестът дава тези две числа във всеки ден от седмицата.
Тестът дава тези две числа в понеделник, в петък и в неделя. OrderService е същият клас, който върви в магазина: тестът сменя само това, което му подава.
По-точно, за любопитните
today и вика self.today(). Програмата подава date.today, без скоби, а тестът lambda: date(2026, 10, 30). Функцията е зависимост като всеки обект.Пробвай
Предвиди изхода
Класовете са от картите преди тази. 31 октомври 2026 е събота. Какво печата програмата?
clock = FakeClock(date(2026, 10, 30))shop = OrderService(clock)clock.day = date(2026, 10, 31)app = OrderService(clock)
print(f"{shop.price(50):.2f} €")print(f"{app.price(50):.2f} €")Пробвай
Намери грешката
Кошницата е Singleton, „за да е една“:
class Cart: def __init__(self): self.items = []
def add(self, item): self.items.append(item)
cart = Cart()Два теста, всеки със своя страница за плащане. check печата OK или какво е очаквал тестът:
first = CheckoutPage()first.add("Кафе")first.add("Чаша")check("Тест 1", first.count(), 2)
second = CheckoutPage()second.add("Кафе")check("Тест 2", second.count(), 1)Тест 1: OKТест 2: FAIL, очаквах 1, получих 3Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?
Избери реда с грешката.
Страницата сама взима единствената кошница. Двете страници, и двата теста, пълнят един и същ обект: вторият тест вижда кафето и чашата на първия. В магазина е още по-лошо: всички клиенти пазаруват в една кошница. Кошницата не е нещо, което е едно за програмата, а е по една на клиент. Поправката: без обекта на ниво модул; страницата получава кошницата в конструктора: CheckoutPage(Cart()). Всеки тест си прави своя кошница, и двата минават.
По-точно, за любопитните
Същата изтичаща кошница се появява и без Singleton, от списък като стойност по подразбиране. items=[] се изпълнява веднъж, когато Python прочете def, и всяко CheckoutPage() получава същия списък:
class CheckoutPage: def __init__(self, items=[]): self.items = items
def add(self, item): self.items.append(item)
def count(self): return len(self.items)first = CheckoutPage()first.add("Кафе")first.add("Чаша")check("Тест 1", first.count(), 2)
second = CheckoutPage()second.add("Кафе")check("Тест 2", second.count(), 1)Тест 1: OKТест 2: FAIL, очаквах 1, получих 3Поправката е items=None и self.items = [] if items is None else items.
Идеята
Кога не ти трябва
Singleton има смисъл, когато второ копие би било грешка, а обектът не пази нищо, което се сменя: настройките, прочетени веднъж при старта, логът, пулът от връзки към базата. Дори тогава е по-добре да се подава отвън, отколкото всеки да посяга към него сам.
Подаването отвън също има цена. Не си струва, когато:
- зависимостта никога не се сменя и не пречи на тестовете.
roundне е зависимост, която се подава; - програмата е малка. Сглобяване на ръка в началото на програмата стига; рамка за DI е за големи приложения;
- конструкторът иска осем зависимости. Това не е проблем на DI: класът прави твърде много.
Упражнение
Black Friday
Реши задачата в редактора си.
Black Friday. На 27 ноември 2026 магазинът дава 30% отстъпка, а в другите петъци пак 10%. Добави правилото в OrderService и го провери с FakeClock за три дати: Black Friday, 30 октомври (петък) и 27 октомври (вторник). Тестът дава еднакъв изход всеки ден.
Black Friday: 70.00 €Петък: 90.00 €Вторник: 100.00 €Още задачи
- Номера на поръчки. Броячът на номерата е Singleton и започва от 1001. Направи го зависимост на
OrderServiceи напиши два теста, които и двата получават 1001 като първи номер. - Кошница за всеки клиент. Поправи грешката от „Намери грешката“ докрай: кошницата не е Singleton, а всяка страница получава своята. Двама клиенти добавят различни артикули и всеки вижда само своите.
- Нощна отстъпка. Между 22:00 и 6:00 отстъпката е 5%. Часовникът вече трябва да дава и часа. Какво сменяш в часовника, и колко теста трябват, за да си сигурен?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове и данни · Ламбди · Шаблони за дизайн · Strategy
- Продължава в: Adapter и Facade · Repository · Магазинът: кой шаблон къде · SOLID без догма
- Задачи по темата: Шаблони за дизайн