Ú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ť ValueError s 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.Lock na 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._balance a self._is_open,
  • podmienky if, porovnávacie operátory a poradie validácií,
  • návratová hodnota cez return,
  • výnimky, príkaz raise a typ ValueError,
  • 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:

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:

  1. Ak účet nie je otvorený, vyhoďte ValueError("account not open").
  2. Ak amount <= 0, vyhoďte ValueError("amount must be greater than 0").
  3. 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:

  1. Ak účet nie je otvorený, vyhoďte ValueError("account not open").
  2. Ak amount <= 0, vyhoďte ValueError("amount must be greater than 0").
  3. Ak amount > balance, vyhoďte ValueError("amount must be less than balance").
  4. 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

  1. Otvorte bank_account.py a ponechajte názvy triedy a metód presne také, aké očakávajú testy.
  2. V __init__ si pripravte interný stav objektu: zámok, otvorenosť účtu a zostatok.
  3. Implementujte open() a get_balance() tak, aby používali zámok, a spustite prvú časť testov.
  4. Doplňte deposit() s kontrolou otvoreného účtu a kladnej sumy. Kontroly aj zmenu zostatku držte v jednom zamknutom bloku.
  5. 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.
  6. Doplňte close() a overte, že po zatvorení operácie vyhadzujú správnu výnimku.
  7. Skontrolujte presné texty výnimiek. Exercism testy porovnávajú aj správu, nie iba typ výnimky.
  8. 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.

Next Post

Bank Account – bankový účet