TI/Wstęp do programowania obiektowego/Zadania

Z Brain-wiki

Boxcar

Model urządzenia pomiarowego

Modelujemy urządzenie typu boxcar: układ pomiarowy dostarcza próbki (liczby zmienno-przecinkowe) sekwencyjnie, a boxcar w każdym momencie przechowuje ostatnie [math]N[/math] z nich i na żądanie zwraca średnią i odchylenie standardowe.

Jeśli pomiar dopiero się zaczął i próbek jest mniej niż [math]N[/math], obliczenia są wykonywane na mniejszej liczbie próbek.

Nasz kod napiszemy w postaci klasy.

Etap 1: Przechowywanie danych

Poszczególne próbki będziemy przechowywać w strukturze danych, która pozwala na dodanie nowego elementu na początek sekwencji i usunięcie starego elementu z końca sekwencji.

W Pythonie jest już struktura, która zapewnia takie możliwości — zwykła lista ([], klasa list).

Zapamiętanie nowej próbki realizujemy jej na początek sekwencji próbek. Po dodaniu nowego elementu sprawdzamy, czy nie mamy zbyt dużo próbek. Jeśli jest ich więcej niż [math]N[/math], to usuwamy najstarszy.

class Boxcar(object):
    def __init__(self, N):
         self.dane = []
         self.N = N

    def nowy_pomiar(self, pomiar):
         self.dane.insert(0, pomiar)
         if len(self.dane) > self.N:
              self.dane.pop()


Konstruując naszą klasę w ten sposób zrealizowaliśmy jeden z postulatów programowania obiektowego: nasze dane nie są bezpośrednio widoczne dla użytkowników naszej klasy Boxcar. Również za to w jaki sposób dane są przechowywane, za inicjalizację odpowiednich struktur odpowiada wyłącznie klasa Boxcar. Użytkownik klasy jedynie tworzy obiekt Boxcar i wywołuje na nim metodę nowy_pomiar. To właśnie enkapsulacja i rozdzielenie obowiązków.

Etap 2: Obliczenia

Do implementacji klasy Boxcar dodajmy metodę srednia.

    def srednia(self):
        suma = sum(self.dane)
        liczba = len(self.dane)
        return suma/liczba

Zadanie 1

Dodaj do klasy metodę dyspersja, która zwróci odchylenie [math]\sigma[/math] pamiętanych próbek od średniej.

Przypomnienie:

[math]\sigma = \sqrt{\sum_1^N \frac{\left(x-\bar x\right)^2}{N(N-1)[/math]
gdzie średnia
[math]\bar x = \sum_1^N \frac{x}{N}[/math]


Zadanie 2

Zaprojektuj tekstową reprezentację obiektu. Dodaj do klasy metodę __str__, która wypisze tekstową reprezentację obiektu klasy.

Zadanie 3

Sprawdź swoją implementację boxcara, wypełniając go liczbami losowymi z rozkładu płaskiego.

Etap 3: Wykorzystanie enkapsulacji

Lista nie jest idealną strukturą danych do przechowania pomiarów ponieważ operacja wstawienia lub usunięcia elementu na początek listy jest operacją niewydajną.

Poszczególne próbki powinniśmy przechowywać w strukturze danych, która pozwala na szybkie dodanie nowego elementu na początek sekwencji i usunięcie starego elementu z końca sekwencji.

W standardowej bibliotece Pythona jest już dostępna właśnie taka struktura: collections.deque.

Przykład użycia klasy deque:

>>> import collections
>>> q = collections.deque()
>>> q.appendleft(1)
>>> q.appendleft(2)
>>> q.appendleft(3)
>>> q
deque([3, 2, 1])

Zmodyfikujmy implementację klasy Boxcar tak, by wykorzystać deque zamiast list. Interfejs klasy ani jej zachowanie nie mają prawa się zmienić!

class Boxcar(object):
    def __init__(self, N):
         self.dane = collections.deque()
         self.N = N

    def nowy_pomiar(self, pomiar):
         self.dane.appendleft(pomiar)
         if len(self.dane) > self.N:
              self.dane.pop()

    # reszta klasy pozostaje bez zmian!

Istnieją dwa podstawowe sposoby wykorzystania kodu jednej klasy przy tworzeniu drugiej — dziedziczenie i kompozycja. W tym wypadku do konstrukcji klasy boxcar wykorzystaliśmy klasę list a potem deque. Zrobiliśmy to poprzez kompozycję: dane są przechowywane w zmiennej self.dane, natomiast klasa boxcar wywołuje metody na tym obiekcie w miarę potrzeby. Alternatywą byłoby zastosowanie dziedziczenia — klasa boxcar dziedziczyłaby po list czy deque. Te dwa rozwiązania możemy podsumować tak: albo boxcar ma kolejkę danych, albo boxcar jest kolejką danych. Dziedziczenie ma tę wadę, że nasza nowa klasa ma zupełnie niepotrzebne metody, np. reverse, odziedzione po klasie macierzystej. O ile funkcjonalność jest podobna przy jednym i drugim rozwiązaniu, to kompozycja jest tutaj rozwiązaniem znacznie bardziej eleganckim. Jest to zgodne z ogólną zasadą dobrego programowania obiektowego, że kompozycja jest lepsza niż dziedziczenie.

Etap 4:tworzenie obiektu typu Boxcar, korzystając z iterowalnego kontenera z danymi

Dokumentacja do obiektu collections.deque pokazuje, że tego typu obiekty można inicjalizować, korzystając z obiektów, które są iterowalne (np. listy). Zmiana konstruktora klasy Boxcar tak, żeby można było wykorzystać tę własność deque byłaby bardzo wygodna, bo umożliwiałaby tworzenie obiektów typu Boxcar w oparciu o istniejące już kontenery z danymi.

Zadanie 4

Zmień konstruktor klasy Boxcar, tak by umożliwiał opcjonalne tworzenie obiektu w oparciu o istniejący już kontener z danymi. Uwzględnij sytuację, w której kontener ten zawiera więcej pomiarów niż założona długość bufora. Postaraj się zmienić kontruktor tak, by był jak najbardziej odporny na próby podania nieodpowiednich danych.


Testowanie (na przykładzie klasy Wektor)

Mamy klasę Wektor. Jak wygodnie sprawdzić czy działa poprawnie? Odpowiedź: py.test.

Ponieważ nasz moduł definiujący Wektor jest króciusieńki, funkcje testujące możemy dodać do tego samego modułu. W przypadku dużych programów tworzy się nawet osobny podkatalog (często tests/), tutaj nie warto. Dopisuję więc fukcje sprawdzające mnożenie:

# ...
# definicja klasy Wektor
# ...
def test_mul():
    w1 = Wektor(1, 2)
    w2 = Wektor(3, 4)
    assert w1 * w2 == 11

def test_mul_with_zeros():
    w1 = Wektor(0, 2)
    w2 = Wektor(3, 0)
    assert w1 * w2 == 0

Aby sprawdzić nasz kod, używamy polecenia py.test:

$ py.test wektor.py -v’’’
============================= test session starts =============================
python: platform linux2 -- Python 2.6.4 -- pytest-1.1.1 -- /usr/bin/python
test object 1: .../wektor/wektor.py

wektor.py:60: test_mul PASS
wektor.py:65: test_mul_zeros PASS

========================== 2 passed in 0.01 seconds ===========================

Działa!

Teraz dodajmy funkcję testującą dodawanie:

# ...
# definicja klasy Wektor
# ...
# testy mnożenia
# ...
def test_add():
    w1 = Wektor(1, 2)
    w2 = Wektor(3, 4)
    assert w1 + w2 == Wektor(4, 6)

Ponownie odpalmy py.test:

$ py.test wektor.py -v’’’
============================= test session starts =============================
python: platform linux2 -- Python 2.6.4 -- pytest-1.1.1 -- /usr/bin/python
test object 1: .../wektor/wektor.py

wektor.py:60: test_mul PASS
wektor.py:65: test_mul_zeros PASS
wektor.py:70: test_add FAIL

================================== FAILURES ===================================
__________________________________ test_add ___________________________________

    def test_add():
        w1 = Wektor(1, 2)
        w2 = Wektor(3, 4)
>       assert w1 + w2 == Wektor(4, 6)
E       assert (<Wektor(1, 2) @140050567247696> + <Wektor(3, 4) @140050567248016>) == <Wektor(4, 6) @140050567291216>
E        +  where <Wektor(4, 6) @140050567291216> = Wektor(4, 6)

wektor.py:73: AssertionError
===================== 1 failed, 2 passed in 0.06 seconds ======================

Nie działa! Dlaczego?

Oczywiście w wyrażeniu w1 + w2 == Wektor(4, 6) wykorzystujemy nie tylko operator +, ale również operator ==, który wywołuje metodę __eq__ obiektu z lewej strony. Ponieważ klasa Wektor nie zawiera definicji __eq__, to zostaje wywołana domyślna wersja tej metody. Domyślna wersja nie wie nic o współrzędnych wektora i po prostu wykonuje porównanie tożsamości obiektów (z grubsza is).

Aby test test_add przeszedł, mamy dwie możliwości. Możemy zmienić funkcję testującą tak, żeby po prostu samemu sprawdzała równość współrzędnych:

# ...
# definicja klasy Wektor
# ...
# testy mnożenia
# ...
def test_add():
       w1 = Wektor(1, 2)
       w2 = Wektor(3, 4)       
       wynik = w1 + w2
       assert wynik.a == 4 and wynik.b == 6

Niestety nie jest to rozwiązanie zbyt eleganckie, w szczególności musimy wiedzieć ile jest współrzędnych oraz jak się nazywają.

Drugą opcją jest dodanie implementacji porównywania wektorów do klasy Wektor. Oczywiście ma to sens tylko wtedy, gdy uważamy, że chcemy mieć porównywanie obiektów niezależnie od testów. Nie należy dodawać funkcjonalności do klasy tylko po to by ułatwić pisanie testów.

# ...
# definicja klasy Wektor
# ...
    def __eq__(self, other):
        return self.a == other.a and self.b == other.b
#
# testy mnożenia
#
def test_add():
    w1 = Wektor(1, 2)
    w2 = Wektor(3, 4)
    assert w1 + w2 == Wektor(4, 6)

Ponowne uruchomienie py.test skutkuje poprawnym wykonaniem trzech testów.

Wykonywalne przykłady w dokumentacji

Docstringi w Wektor są nieco zbyt zwięzłe. Bardzo wygodne dla użytkownika są proste wykonywalne przykłady, które można przekleić do interpretera i trochę poeksperymentować. Zapisuje się je w docstringu. Znaczki >>> poprzedzają fragmenty do wykonania, a wyniki wypisywane przez Pythona się po prostu poprzedza spacjami. Łatwo się domyślić, że tak jest dlatego, że można po prostu przekleić wykonywane przykłady z interaktywnej sesji, nie trzeba niczego wymyślać.

Spróbujmy:

class Wektor(object):
    """Dwuwymiarowy wektor.

    >>> print Wektor(3, 5)
    (3, 5)

    >>> print Wektor(3, 5) + Wektor(-1, -1)
    (2, 4)
    """
    # ...
    # reszta definicji klasy
    #

Teraz osoba pisząca help(Wektor) od razu zorientuje się jak można użyć tej klasy. Polecenia print użyłem zamiast po prostu wykonania wyrażenia tworzącego nowy Wektor po to, by uniknąć nieistotnych szczegółów, np. wypisywania adresu przez Wektor.__repr__.

Testowanie z wykorzystaniem docstringów

Jeśli ktoś postanawia skorzystać z wykonywalnych poleceń zapisanych w docstringach, a nie działają one tak jak należy, zazwyczaj nie jest szczęśliwy. Dlatego należy sprawdzać, czy zapisane przykłady zachowują się nadal tak, jak zachowywały się kiedy osoba pisząca dokumentację wkleiła je do niej.

Służy do tego moduł doctest.

$ python -m doctest -v wektor.py
Trying:
    print Wektor(3, 5)
Expecting:
    (3, 5)
ok
Trying:
    print Wektor(3, 5) + Wektor(-1, -1)
Expecting:
    (2, 4)
ok
15 items had no tests:
   # ...
   # lista funkcji bez testów pominięta
   # ...
1 items passed all tests:
   2 tests in wektor.Wektor
2 tests in 16 items.
2 passed and 0 failed.
Test passed.

Również py.test pozwala na wykorzystanie wykonywalnych docstringów:

$ python -m doctest -v wektor.py

Takie wywołanie również skutkuje modułu doctest i daje identyczny efekt.

Sprawdzanie pokrycia kodu przez testy

Uważa się, że w dobrze przetestowanym projekcie, liczba linii kodu w fukcjach testujących to 100-200% linii kodu w głównej części. Na pewno nie jest łatwo odpowiedzieć na pytanie, czy nasze testy faktycznie sprawdzają wszystko co należy. Niemniej często łatwo jest powiedzieć czego nie sprawdzają — jeśli jakaś funkcja czy fragement kodu w trakcie testów nie jest wogóle uruchamiany, to znaczy, że z całą pewnością nie jest testowany.

Do sprawdzania pokrycia kodu przez testy w połączeniu z py.test służy wtyczka figleaf:

$ py.test wektor.py -v
============================= test session starts =============================
python: platform linux2 -- Python 2.6.4 -- pytest-1.1.1 -- /usr/bin/python
test object 1: .../wektor/wektor.py

wektor.py:60: test_mul PASS
wektor.py:65: test_mul_zeros PASS
wektor.py:70: test_add PASS
----------------------------------- figleaf -----------------------------------
Writing figleaf data to .../wektor/.figleaf
Writing figleaf html to file://.../wektor/html

========================== 3 passed in 0.08 seconds ===========================

Wtyczka figleaf powoduje wygenerowanie plików HTML zawierających kopię kodu programu z liniami oznaczonymi kolorami.

Zielony oznacza, że linia została wykonana w trakcie testów przynajmniej raz
Czerwony oznacza, że linia nie była wogóle użyta
Czarny oznacza, że linia nie zawiera wykonywalnego kodu, a np. komentarz czy docstring.
source file: /home/zbyszek/python/wektor/wektor.py
file stats: 33 lines, 24 executed: 72.7% covered 
  1. # -*- coding: utf-8 -*-
  2. class Wektor(object):
  3.     """Dwuwymiarowy wektor."""
  4. 
  5.     _ile_nas = 0
  6. 
  7.     def __init__(self, a, b):
  8.         self.a = a
  9.         self.b = b
 10.         Wektor._ile_nas += 1
 11. 
 12.     def dlugosc(self):
 13.         """Zwraca liczbę, długość Wektora."""
 14.         return (self.a**2 + self.b**2)**0.5
 # ...
 # reszta pominięta
 #

Dzięki takiej pomocy możemy łatwo zobaczyć które fragmenty programu wymagają napisania więcej testów. W tym przykładzie widać, że metoda Wektor.dlugosc jest takim fragmentem.

Zadanie 1

Dopisać program testujący powyższą klasę.

Zadanie 2

Napisz klasę Kwadrat. Obiekty tej klasy powinny:

  • przechowywać długość boku kwadratu
  • posiadać reprezentację napisową
  • posiadać metody zwracające pole i obwód kwadratu
  • operator dodawania zdefiniowany tak, aby obiekt powstały w wyniku dodawania dwóch kwadratów miał pole równe sumie pól kwadratów składowych

Następnie napisz program testujący tęże klasę.

Dodatki

Wczytywanie pliku raz jeszcze

Czy pamiętasz zadanie, w którym należało wczytać pliki i narysować widmo z zadanego kanału?

Zadanie 1

Zaprojektuj własną klasę, która zapewni obsługę pliku binarnego, zawierającego multipleksowany sygnał o zadanej częstości próbkowania, liczbie kanałów, typie liczb. Zastanów się jakie jeszcze mogą być parametry takiego pliku. Zastanów się, jakie są jej niezbędne pola i napisz jej konstruktor i metodę __str__.

Zastanów się, jak najwygodniej będzie leniwemu użytkownikowi korzystać z obiektów zaprojektowanej i zaimplementowanej przez siebie klasy. Zastanów się, czy nie warto jest wczytywać pliku od razu w konstruktorze klasy (przekazywać nazwę pliku, częstość próbkowania i liczbę kanałów, jako argumenty konstruktora klasy.

Zastanów się, jak zaprojektować metodę __str__, żeby nie wypisywała całego sygnału na ekran, ale wyświetlała najważniejsze o nim informacje.

Zadanie 2

Do zaprojektowanej klasy dodaj następujące metody:

  • zwracającą cały sygnał z wybranego kanału (możesz w konstruktorze dodać opisy kanałów, jeżeli dysponujesz opisem pliku i zawrzeć metodę, która zwraca sygnał z elektrody o odpowiedniej nazwie),
  • zwracającą wybrany fragment sygnału z wybranej elektrody (np. sygnał od sekundy [math]t_1[/math] do sekundy [math]t_2[/math]),
  • zwracającą wybrany fragment sygnału z wszystkich elektrod (np. od sekundy [math]t_1[/math] do sekundy [math]t_2[/math]),
  • rysującą widmo wybranego fragmentu sygnału z wybranej elektrody,
  • rysującą widmo fragmentu wszystkich sygnałów z wszystkich elektrod.
  • zwracającą fragmenty sygnału o zadanej długości z wybranej elektrody, zaczynające się od kolejnych triggerów. Załóż, że w kanale z danym triggerem, wystąpienie triggera jest zaznaczone 1 (wartość 0, oznacza brak triggera),
  • przygotuj się na możliwość występowania kanału specjalnego z triggerem (w takiej formie jak na Pracowni Sygnałów Bioelektrycznych). Dodaj metody uśredniające fragmenty sygnału o zadanej długości z wybranej elektrody, zaczynające się od kolejnych triggerów i rysujące ich widmo. Zastanów się jak najlepiej przekazywać informację o tym, że któryś kanał jest kanałem "specjalnym", np. triggerem. Do przetestowania cięcia danych wg triggera można użyć stąd (wybrane pliki .raw), lub danych samodzielnie zebranych na pracowni,
  • stwórz klasę, dziedziczącą po powyższej, przedefiniowującą metody do wczytywania danych tak, aby obsługiwać duże pliki. To znaczy nie trzymającą całych danych w pamięci, lecz umiejącą wczytać wskazany fragment danych — wybrany kawałek, od sekundy t1, do t2, z kanałów podanych w liście kanalów. We wczytywaniu pliku nie korzystaj z metody fromfile. Otwórz plik metodą open, następnie wylicz do którego miejsca w pliku masz przejść używając metody seek obiektu typu plik (podaj o ile bajtów ma się przesunąć) i wczytaj kolejne próbki metodą read. Argumentem metody read jest liczba bajtów do wczytania, upewnij się więc, czy pamiętasz, że liczby typu double zajmują 8 bajtów. Tak wczytane próbki musisz jeszcze zamienić ze stringów na liczby typu double (pamiętając o porządku zapisu bajtów), korzystając np. z funkcji unpack modułu struct.


Zadanie 3

Spróbuj przeciążyć metodę __init__, tak żeby można było konstruować obiekty, korzystając z nazwy pliku (wczytując go w konstruktorze) albo z wektora z danymi.

Szkic prostszej metody:

def __init__(self, dane=None, plik=None):
    if (dane is None) + (plik is None) != 1:
        ... #np. sys.exit(1)

Szkic bardziej skomplikowanej metody (wykorzystującej dekoratory):

class Dane(object):
    def __init__(self, dane):
        self.dane = dane
    @classmethod
    def fromfile(cls, name):
        return cls(np.fromfile(dane))


Zadanie 4

Do klasy z dwóch pierwszych zadań dodaj przeciążony operator addycji (dodawania), który tworzyć będzie obiekt z sygnałem, składającym się z obu sygnałów. Pamiętaj o sprawdzeniu, że sygnały są tego samego typu — np. mają tyle samo kanałów i tę samą częstość próbkowania. Czy są jeszcze jakieś operatory, które warto w tej sytuacji przeładować?

Zadanie 5

Zaprojektuj klasę zapewniającą obsługę pliku z danymi kalibrującymi z IV BCI Competition. Przypomnij sobie opis tych danych. Napisz konstruktor, w którym będziesz wczytywać plik i metodę __str__, która będzie zwracać najważniejsze informacje na temat danych zawartych w pliku.

Zadanie 6

Do zaprojektowanej przez siebie powyżej klasy, dodaj metody, które:

  • zwrócą sygnał z wybranego kanału,
  • zwroci listę wszystkich fragmentów sygnału odpowiadający danej klasie bodźca i danemu kanałowi,
  • zwrócą uśredniony sygnał odpowiadający danej klasie bodźca i danemu kanałowi (uśredniamy "triale"),
  • narysują i zapiszą do pliku widma z triali odpowiadających danej klasie bodźca i danemu kanałowi (w nazwie pliku ma się znaleźć informacja o tym dla którego triala, której klasy i którego kanału jest to widmo),
  • narysują widmo po uśrednieniu widm ze wszystkich triali dla danej klasy bodźca.