Wprowadzenie
Test driven development - czyli jak poprawnie pisać oprogramowanie
Krok 1.1 - zbudujmy prosty kalkulator z dodawaniem w Python
Krok 1.2 - dodajmy do kalkulatora odejmowanie
Krok 1.3 - dodajmy do kalkulatora mnożenie
Krok 1.4 - dodajmy do kalkulatora dzielenie
Krok 1.5 - obsługa dzielenia przez zero
Krok 1.5 - nieprawidłowy operator
Krok 2 - refaktoryzacja kalkulatora w Python
Krok 3 - słowniki zamiast serii if
Krok 4 - domyślny parametr w funkcji get
Krok 5 - biblioteka operator w Python
Krok 6 - interfejs użytkownika
Krok 7 - poprawa interfejsu
Krok 8 - kolejna optymalizacja interfejsu
Podsumowanie
Spis treści
Każdy, kto zajmuje się programowaniem, na początku swojej kariery spotkał się z zadaniami typu kalkulator. Ja pisałem proste kalkulatory, za każdym razem jak zmieniałem język programowania, a było tego sporo. Dziś chciałbym pokazać Ci jak napisać taki kalkulator w sposób bardziej zbliżony do codziennego wytwarzania oprogramowania. Zaciekawiłem Cię? To do roboty
Wprowadzenie
Widziałem masę poradników do tworzenia kalkulatorów i mam wrażenie, że wszyscy zaczynają niejako od środka, bo od podawania wartości od użytkownika. Przez to testowanie jest bardzo trudne. Spróbujmy zrobić to odwrotnie, czyli na początek zajmiemy się silnikiem do kalkulatora, a dopiero później przejdziemy do interfejsu użytkownika.
Test driven development - czyli jak poprawnie pisać oprogramowanie
Na początku kariery nie myśli się o testach. Przynajmniej Ja nie myślałem, bo chciałem jak najszybciej pisać kolejne poprawki. Ale to błąd, bo jak później chciałem nanosić poprawki, to okazywało się, że psułem coś, czego wcześniej nie sprawdziłem. Pisząc też zgodnie z TDD od razu myślimy o tym co oczekujemy na wejściu i wyjściu, a sposób implementacji schodzi na drugi plan. Ale starczy o zaletach TDD. Przejdźmy do rzeczy i napiszmy pierwszy test. Tu drobna uwaga. Skorzystałem tu z unittest, ale równie dobrze można to napisać poprzez pytest, które nawet byłoby lepsze, dzięki dekoratorowi pytest.mark.parametrize, ale niech już tak zostanie. Stwórzmy zatem dwa pliki test_calc.py oraz calc.py.
Krok 1.1 - zbudujmy prosty kalkulator z dodawaniem w Python
Niżej szybki przykład testu na dodawanie
[ .. ] # test_calc.py
import unittest
from calc import calc
class TestCalc(unittest.TestCase):
def test_add(self):
result = calc("+", 7, 3)
self.assertEqual(10, result)
if __name__ == '__main__':
unittest.main()
Test będzie na czerwono, bo nie mamy nawet funkcji calc . Zajmijmy się tym
[ .. ] # calc.py
def calc(operator, x, y):
if operator == "+":
return x + y
Krok 1.2 - dodajmy do kalkulatora odejmowanie
Zacznijmy oczywiście od kolejnego testu
[ .. ] # test_calc.py
def test_subtract(self):
result = calc("-", 7, 3)
self.assertEqual(4, result)
Teraz rozbudujmy kalkulator
[ .. ] # calc.py
def calc(operator, x, y):
if operator == "+":
return x + y
elif operator == "-":
return x - y
Krok 1.3 - dodajmy do kalkulatora mnożenie
Ponownie, zaczynamy od testu
[ .. ] # test_calc.py
def test_multiply(self):
result = calc("*", 5, 6)
self.assertEqual(30, result)
Teraz dopisujemy logikę mnożenia
[ .. ] # calc.py
def calc(operator, x, y):
if operator == "+":
return x + y
elif operator == "-":
return x - y
elif operator == "*":
return x * y
Krok 1.4 - dodajmy do kalkulatora dzielenie
Dzielenie jest nieco bardziej skomplikowane, bo należy uwzględnić resztę z dzielenie. Dopiszmy zatem nieco bardziej rozbudowany test, w którym sprawdzimy od razu 3 przypadki.
[ .. ] # test_calc.py
def test_divide(self):
for x, y, result in [(6, 3, 2), (1, 2, 0.5), (-10, -5, 2)]:
calculated_result = calc("/", x, y)
self.assertEqual(result, calculated_result)
Osoby, które nie są zaznajomione z takim rozpakowywaniem wartości , mogą spokojnie dopisać kilka testów jak wcześniej. No to teraz czas na kod
[ .. ] # calc.py
def calc(operator, x, y):
if operator == "+":
return x + y
elif operator == "-":
return x - y
elif operator == "*":
return x * y
elif operator == "/":
return x / y
Krok 1.5 - obsługa dzielenia przez zero
Wiemy, że nie można dzielić przez zero. Można to rozwiązać na wiele sposobów. My do naszych potrzeb przyjmijmy, że zostajemy przy wyjątku. Zatem kod się nie zmieni ale dopiszmy brakujący test
[ .. ] # test_calc.py
def test_divide_with_y_as_zero(self):
with self.assertRaises(ZeroDivisionError):
calc("/", 1, 0)
Krok 1.5 - nieprawidłowy operator
To ostatni krok, o którym łatwo zapomnieć. Dobrze jest obsłużyć przypadek w którym podamy operator, którego nasz kalkulator nie obsługuje. Przyjmijmy, że w takiej sytuacji zwracamy None . Jako, że to ostatni test, to wkleje wszystkie testy, żebyśmy mieli to niejako podsumowane
[ .. ] # test_calc.py
import unittest
from calc import calc
class TestCalc(unittest.TestCase):
def test_add(self):
result = calc("+", 7, 3)
self.assertEqual(10, result)
def test_subtract(self):
result = calc("-", 7, 3)
self.assertEqual(4, result)
def test_multiply(self):
result = calc("*", 5, 6)
self.assertEqual(30, result)
def test_divide(self):
for x, y, result in [(6, 3, 2), (1, 2, 0.5), (-10, -5, 2)]:
calculated_result = calc("/", x, y)
self.assertEqual(result, calculated_result)
def test_divide_with_y_as_zero(self):
with self.assertRaises(ZeroDivisionError):
calc("/", 1, 0)
def test_with_invalid_operation(self):
result = calc("s", 1, 0)
self.assertIsNone(result)
if __name__ == '__main__':
unittest.main()
Zatem cały kalkulator powinien wyglądać mniej więcej tak
[ .. ] # calc.py
def calc(operator, x, y):
if operator == "+":
return x + y
elif operator == "-":
return x - y
elif operator == "*":
return x * y
elif operator == "/":
return x / y
else:
return None # Na razie niech będzie return None.
Krok 2 - refaktoryzacja kalkulatora w Python
Tak przygotowany kalkulator spełnia już swoją funkcję, ale w codziennej pracy raczej unika wrzucana się takiej logiki do jednej funkcji. Częściej jest to rozbijane na mniejsze składowe. Jako, że mamy testy, to możemy to robić bezpiecznie. Wynieśmy zatem operacje do osobnych funkcji.
[ .. ] # calc.py
def add(x, y):
return x + y
def subtract(x, y):
return x - y
def multiply(x, y):
return x * y
def divide(x, y):
return x / y
def calc(operator, x, y):
if operator == "+":
return add(x, y)
elif operator == "-":
return subtract(x, y)
elif operator == "*":
return multiply(x, y)
elif operator == "/":
return divide(x, y)
else:
return None
Krok 3 - słowniki zamiast serii if
Ktoś może słusznie zauważyć, że powyższa refaktoryzacja jedyne co przyniosła to rozwleczenie kodu. I w sumie to prawda. Ale jest to dopiero pierwszy krok poprawy kodu. Przejdźmy zatem do kolejnego kroku refaktoryzacji i zamiast serii if skorzystajmy z potęgi słowników w Python.
[ .. ] # calc.py - metody
def calc(operator, x, y):
operators = {"+": add, "-": subtract, "*": multiply, "/": divide}
method = operators.get(operator)
if method:
return method(x, y)
return None
Teraz poszczególne operatory sa jako klucze w słowniku, a pod wartościami trzymamy odniesienia do funkcji. Dzięki temu logika kalkulatora znacznie się uprościła.
Krok 4 - domyślny parametr w funkcji get
W poprzednim wpisie pisałem, kiedy należy uważać na opcjonalny parametr w metodzie get do pobierania wartości ze słownika. Teraz wykorzystamy tę cechę na nasza korzyść. Żeby uniknąć sprawdzenia czy pobrało metodę spod konkretnego klucza, dodajmy taki
[ .. ] # calc.py - metody
def calc(operator, x, y):
operators = {"+": add, "-": subtract, "*": multiply, "/": divide}
method = operators.get(operator, lambda x, y: None) # domyslnie lambda, która zwraca None
Krok 5 - biblioteka operator w Python
Ogromną zaletą języka Python jest duża liczba wbudowanych bibliotek, które pozwalają tworzyć prawdziwe cuda. Nie czekajmy zatem i zróbmy te cuda!
[ .. ] # calc.py
from operator import truediv, mul, add, sub
def calc(operator, x, y):
operators = {"+": add, "-": subtract, "*": mul, "/": truediv}
method = operators.get(operator, lambda x,y: None)
return method(x, y)
I teraz widzimy, że w 4 krokach udało nam się z prostego kalkulatora zrobić kalkulator, którego nie powstydziłby się żaden programista Python :)
Krok 6 - interfejs użytkownika
Mamy już cały silnik napisany i przetestowany. Teraz musimy stworzyć interfejs, najszybciej konsolowy, żeby użytkownik mógł korzystać z naszego kalkulatora
[ .. ] # calc.py
def calculator():
operation = input(
"""
Podaj operację jaką chcesz wykonać
+ dodawanie
- odejmowanie
* mnożenie
/ dzielenie
"""
)
x = float(input("Podaj pierwszą liczbę: "))
y = float(input("Podaj drugą liczbę: "))
result = calc(operation, x, y)
print(result)
Krok 7 - poprawa interfejsu
Nie jestem zwolennikiem pisania testów pod metody w interfejsem, dlatego tutaj już testów nie będę pisał. Z testów wiemy natomiast, że są jeszcze dwa przypadki, których nie mamy obsłużonych. Chodzi mianowicie o dzielenie przez zero oraz niepoprawny operator. Poprawiony kod może wyglądać następująco
[ .. ] # calc.py
def calculator():
operation = input(
"""
Podaj operację jaką chcesz wykonać
+ dodawanie
- odejmowanie
* mnożenie
/ dzielenie
"""
)
x = float(input("Podaj pierwszą liczbę: "))
y = float(input("Podaj drugą liczbę: "))
try:
result = calc(operation, x, y)
except ZeroDivisionError:
print("Nie można dzielić przez zero")
else:
print(result if result is not None else "Niedozwolona operacja")
Krok 8 - kolejna optymalizacja interfejsu
Znowu jednolinijkowe formaty. Gdyby tylko dało się ich jakoś pozbyć? W sumie da się. Jeśli na wcześniejszym etapie wymusimy na użytkowniku podanie poprawnego operatora to ten warunek nie będzie potrzebny. Zatem dodajmy juz ostatnią poprawkę
[ .. ] # calc.py
# krok 8 - dynamiczne menu:
def calculator():
operation = None
allowed_operations = ["+", "-", "*", "/"]
while operation not in allowed_operations: # Wymuszamy na użytkowniku
operation = input(
"""
Podaj operację jaką chcesz wykonać
+ dodawanie
- odejmowanie
* mnożenie
/ dzielenie
"""
)
x = float(input("Podaj pierwszą liczbę: "))
y = float(input("Podaj drugą liczbę: "))
try:
result = calc(operation, x, y)
except ZeroDivisionError:
print("Nie można dzielić przez zero")
else:
print(result)
Podsumowanie
Oczywiście ten kalkulator ma jeszcze przynajmniej dwie kwestie, które warto poprawić. Po pierwsze podana liczba od użytkownika nie zawsze musi być liczbą. Warto by to obsłużyć. Po drugie mamy jeden poważny błąd w architekturze tego rozwiązania. Chodzi o to, że w dwóch miejsach trzymamy dozwolone operacje, jedna w silniku do obliczeń, a druga w menu. Dużo praktyczniej byłoby to zrealizować tak, że silnik jest na przykład klasą i udostępniałby listę dozwolonych klas. Taką proziworyczną klasę można zobaczyć niżej.
[ .. ] # calc.py
from operator import truediv, mul, add, sub
class Calc:
def __init__(self):
self.operators = {"+": add, "-": subtract, "*": mul, "/": truediv}
def get_allowed_operators:
return self.operators.keys()
def calc(self, x, y):
x = self.validate(x)
y = self.validate(y)
def validate(self, num):
# to jakaś walidacja, a może nawet przygotowanie danych
# dane mogą być w różnym formacie
# 1.5
# 2
# 4,8
Jak pisałem, jest to tylko prowizoryczna klasa kalkulatora. Zachęcam do eksperymentowania. Może zrobisz swój własny kalkulator w Python i będzie on lepszy od mojego? Koniecznie podziel się swoim rozwiązaniem na GitHub, jak tylko to zrobisz, a w komentarzu daj link :)
Napisanie prostego kalkulatora w Python może zająć mniej niż 2 minuty. Nie chciałem, żeby to było kopia wszystkiego, co już znajdziecie internecie. Chciałem przede wszystkim pokazać, że nawet takie proste zadania można robić zgodnie ze wszystkimi dobrymi praktykami. Jeśli uważasz, że treści są wartościowe, to dziel się nimi w internecie. A może jakiś twój znajomy właśnie rozpoczyna naukę i ten wpis mu się przyda?