Jak poprawnie stworzyć kalkulator w Python


Zobacz jak zrobić kalkulator w Python korzystając ze słowników, operatorów i to wszystko zgodnie z najlepszymi praktykami programistycznymi.


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ózmy 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

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

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

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

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

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

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

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.

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!

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

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

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ę

# 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.

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 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łasnie rozpoczyna naukę i ten wpis mu się przyda?

Kwi 20, 2020

Najnowsze wpisy

Zobacz wszystkie