Към съдържанието
wannacode

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, както го дава езикът

В урока „Шаблони за дизайн“ видя Singleton като клас, който пази единствения си обект. В Python рядко се прави така, защото езикът вече има нещо, което се изпълнява само веднъж: модулът.Модулът (файлът 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} €")
Стъпка по стъпка, с думи
  1. Преди price сам посягаше към clock на ниво модул: скрита връзка към глобален обект, която тестът не може да смени.
  2. Тестът си прави свой часовник: FakeClock, който казва 2026-10-30, петък.
  3. Часовникът влиза отвън, през конструктора. OrderService пази само референция към него.
  4. price(100): услугата пита своя часовник. Петък е, и сумата е 90.00.
  5. Тестът мести датата на 2026-10-27, вторник. В OrderService не се пипа нищо.
  6. Същото 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

Всеки тест поотделно минава. Заедно вторият пада. На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

Същата изтичаща кошница се появява и без 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%. Часовникът вече трябва да дава и часа. Какво сменяш в часовника, и колко теста трябват, за да си сигурен?

Тест

Провери се

1. Защо тестът за петъчната отстъпка минава само в петък?
2. Какво променя подаването на зависимостта (Dependency Injection)?
3. Два теста ползват една и съща кошница, Singleton. Първият добавя два артикула, вторият един и очаква 1. Какво става?
4. Кога Singleton е на място?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·