Úloha: Bank Account na Exercisme
Čo sa v cvičení naučíte
Po vyriešení cvičenia budete vedieť:
- navrhnúť jednoduchú triedu, ktorá si pamätá vlastný stav,
- rozlíšiť stav otvoreného a zatvoreného bankového účtu,
- inicializovať atribúty objektu v metóde
__init__, - zapisovať metódy, ktoré menia vnútorný stav objektu,
- kontrolovať platnosť operácie pred tým, ako zmeníte dáta,
- vyhadzovať
ValueErrors presnou chybovou správou, - čítať automatické testy ako presnú špecifikáciu správania,
- pochopiť, prečo sú operácie typu „prečítaj hodnotu, uprav ju, zapíš ju späť“ problematické pri paralelnom prístupe,
- použiť
threading.Lockna ochranu kritickej sekcie pri prístupe z viacerých vlákien, - napísať riešenie, ktoré obstojí aj v testoch na race condition.
Čo si musíte preštudovať
Pred riešením si zopakujte najmä tieto témy:
- triedy, objekty, metódy a parameter
self, - metóda
__init__a inštančné atribúty, - zmena stavu objektu cez priradenie do
self._balanceaself._is_open, - podmienky
if, porovnávacie operátory a poradie validácií, - návratová hodnota cez
return, - výnimky, príkaz
raisea typValueError, - rozdiel medzi chybou „účet nie je otvorený“, „účet už je otvorený“ a „suma operácie je neplatná“,
- základy jednotkových testov v module
unittest, - základy vlákien, zámkov a kritických sekcií.
Užitočné kapitoly v oficiálnom Python manuáli:
- Python Tutorial – Classes,
- Class Objects,
- Instance Objects,
- Method Objects,
- Errors and Exceptions,
- The
raisestatement, -
ValueError, unittest– Unit testing framework,threading– Thread-based parallelism,- Global Interpreter Lock,
Lockobjects,- The
withstatement, aby ste vedeli zámok používať ako kontextový manažér.
Porozumejte zadaniu
V súbore bank_account.py máte doplniť triedu BankAccount s metódami:
class BankAccount:
def __init__(self):
pass
def get_balance(self):
pass
def open(self):
pass
def deposit(self, amount):
pass
def withdraw(self, amount):
pass
def close(self):
pass
Účet sa po vytvorení objektu nemá považovať za otvorený. Najprv treba zavolať
open(). Až potom je dovolené zistiť zostatok, vložiť peniaze, vybrať peniaze
alebo účet zatvoriť.
V tomto cvičení rátajte s tým, že testy overujú aj race condition. Nestačí teda,
aby metódy fungovali iba pri sekvenčnom volaní. deposit() a withdraw() musia
byť bezpečné aj vtedy, keď ich nad tým istým objektom zavolá viac vlákien naraz.
Stav objektu
Objekt potrebuje aspoň tri informácie:
- či je účet otvorený,
- aký je aktuálny zostatok,
- zámok, ktorý chráni prístup k stavu účtu.
Praktický návrh:
import threading
self._lock = threading.Lock()
self._is_open = False
self._balance = 0
Podčiarkovník v názve atribútu je bežná konvencia: atribút je interný detail triedy a ostatný kód by ho nemal meniť priamo.
Každý účet má mať vlastný zámok. Nepoužívajte jeden globálny zámok pre všetky
účty, lebo by ste zbytočne blokovali aj nezávislé účty. Každú operáciu, ktorá
číta alebo mení stav konkrétneho účtu, vykonajte v bloku with self._lock:.
Presné pravidlá metód
__init__(self)
Inicializujte zatvorený účet s nulovým zostatkom a vytvorte zámok pre tento objekt. Metóda nič nevracia. Nezabudnite predtým importovať modul:
import threading
open(self)
Metóda otvorí účet a nastaví zostatok na 0.
Ak je účet už otvorený, musí vyhodiť:
raise ValueError("account already open")
Dôležitý detail: ak bol účet v minulosti otvorený, bol na ňom zostatok a potom
sa zatvoril, ďalšie open() má začať opäť od nuly. Starý zostatok sa nemá
pamätať.
Aj open() chráňte zámkom. Inak by dve vlákna mohli naraz zistiť, že účet ešte
nie je otvorený, a obe by pokračovali ako úspešné otvorenie.
get_balance(self)
Metóda vráti aktuálny zostatok.
Ak účet nie je otvorený, musí vyhodiť:
raise ValueError("account not open")
Metóda nemá vypisovať zostatok cez print. Testy kontrolujú návratovú hodnotu.
Aj čítanie zostatku robte pod zámkom. Bez zámku by test mohol čítať zostatok práve počas inej operácie, ktorá ho mení.
deposit(self, amount)
Metóda vloží kladnú sumu na otvorený účet.
Poradie kontrol:
- Ak účet nie je otvorený, vyhoďte
ValueError("account not open"). - Ak
amount <= 0, vyhoďteValueError("amount must be greater than 0"). - Inak pripočítajte sumu k zostatku.
Vklad nemá nič vracať. Stačí zmeniť vnútorný stav objektu.
Kontroly aj pripočítanie musia byť v jednom zamknutom bloku. Zápis typu
self._balance += amount je zložená operácia: najprv sa hodnota prečíta, potom
sa vypočíta nová hodnota a až potom sa zapíše späť. Bez zámku sa medzi tieto
kroky môže dostať iné vlákno.
withdraw(self, amount)
Metóda vyberie kladnú sumu z otvoreného účtu.
Poradie kontrol:
- Ak účet nie je otvorený, vyhoďte
ValueError("account not open"). - Ak
amount <= 0, vyhoďteValueError("amount must be greater than 0"). - Ak
amount > balance, vyhoďteValueError("amount must be less than balance"). - Inak odpočítajte sumu od zostatku.
Vybrať celý zostatok je dovolené. Napríklad pri zostatku 20 je výber 20
platný a výsledný zostatok je 0, hoci text chybovej správy pri príliš veľkom
výbere hovorí „less than balance“.
Kontrola dostatočného zostatku a samotné odpočítanie musia byť v jednom zamknutom bloku. Inak by dve vlákna mohli obe prejsť kontrolou a spolu vybrať viac peňazí, než je na účte.
close(self)
Metóda zatvorí otvorený účet.
Ak účet nie je otvorený, musí vyhodiť:
raise ValueError("account not open")
Po zatvorení nesmie fungovať get_balance(), deposit(), withdraw() ani
ďalšie close(). Neskoršie open() je dovolené a začína s nulovým zostatkom.
Aj zatvorenie účtu chráňte zámkom, aby sa nestalo, že jedno vlákno účet zatvára a iné vlákno zároveň mení jeho zostatok.
Odporúčaný postup riešenia
- Otvorte
bank_account.pya ponechajte názvy triedy a metód presne také, aké očakávajú testy. - V
__init__si pripravte interný stav objektu: zámok, otvorenosť účtu a zostatok. - Implementujte
open()aget_balance()tak, aby používali zámok, a spustite prvú časť testov. - Doplňte
deposit()s kontrolou otvoreného účtu a kladnej sumy. Kontroly aj zmenu zostatku držte v jednom zamknutom bloku. - Doplňte
withdraw()s kontrolou otvoreného účtu, kladnej sumy a dostatočného zostatku. Aj tu musí byť kontrola aj zmena zostatku v jednom zamknutom bloku. - Doplňte
close()a overte, že po zatvorení operácie vyhadzujú správnu výnimku. - Skontrolujte presné texty výnimiek. Exercism testy porovnávajú aj správu, nie iba typ výnimky.
- Spustite aj testy, ktoré robia veľa vkladov a výberov z viacerých vlákien. Výsledný zostatok musí byť rovnaký pri každom spustení.
Race condition a zámky
Pretekový stav by vznikol napríklad vtedy, keby dve vlákna naraz vykonali vklad:
vlákno A prečíta balance = 100
vlákno B prečíta balance = 100
vlákno A zapíše balance = 150
vlákno B zapíše balance = 130
Správny výsledok mal byť 180, ale jedna zmena sa stratila. Zámok zabezpečí,
že k čítaniu a zápisu zostatku sa v jednom okamihu dostane iba jedno vlákno.
Nespoliehajte sa na GIL: ten neznamená, že celá banková operácia je atomická.
Základný tvar:
with self._lock:
# kontrola stavu a zmena zostatku patria do tej istej kritickej sekcie
Do zamknutej časti patrí celá operácia: kontrola, výpočet aj zápis. Keby ste zamkli iba samotné priradenie, stále by mohlo iné vlákno zmeniť zostatok medzi kontrolou a zápisom.
Pozor na jednu vec: ak použijete obyčajný threading.Lock, nevolajte zo
zamknutej metódy inú verejnú metódu, ktorá sa pokúsi zamknúť ten istý zámok.
Program by sa mohol zaseknúť. Preto je jednoduchšie mať krátke súkromné
kontroly alebo písať validácie priamo v každej metóde.
Kontrolné príklady
Toto správanie by ste mali vedieť vysvetliť ešte pred spustením testov:
account = BankAccount()
account.open()
account.get_balance()
# 0
account = BankAccount()
account.open()
account.deposit(100)
account.withdraw(75)
account.get_balance()
# 25
account = BankAccount()
account.deposit(50)
# ValueError: account not open
account = BankAccount()
account.open()
account.deposit(50)
account.close()
account.open()
account.get_balance()
# 0
Pred odovzdaním si overte najmä tieto okrajové prípady:
- zistenie zostatku na zatvorenom účte,
- vklad na neotvorený alebo zatvorený účet,
- výber zo zatvoreného účtu,
- otvorenie už otvoreného účtu,
- zatvorenie neotvoreného účtu,
- záporný alebo nulový vklad,
- záporný alebo nulový výber,
- výber väčší než aktuálny zostatok,
- výber presne celého zostatku,
- znovuotvorenie účtu po zatvorení.
Najčastejšie chyby
- Zabudnete, že nový objekt ešte nie je otvorený.
- Pri
open()nenastavíte zostatok na nulu, takže znovuotvorený účet si pamätá starý zostatok. - Vyhodíte
ValueError, ale bez presnej správy, ktorú čakajú testy. - Pri výbere zakážete aj
amount == balance, hoci vybrať celý zostatok je dovolené. - Validácie spravíte v inom poradí a test dostane inú chybu, než očakáva.
- Pri pokuse o vláknovú bezpečnosť zamknete iba časť operácie, nie celú kritickú sekciu.