Ten artykuł pokazuje kolejną odsłonę projektu Pogodynka. Jeśli jesteś zainteresowany starszą wersją odsyłam Cię do serii poprzednich artykułów:
Starsza wersja pogodynki zawierała więcej komponentów, nad którymi pracowałem samodzielnie. Wówczas postawiłem na serwer współdzielony, na którym zainstalowałem niezbędne elementy takie jak silnik bazy danych czy serwer HTTP. Stary diagram wygląda tak:
Takie podejście ma swoje wady i zalety. Niestety w skali tak małego projektu doświadczałem głównie wad:
Biorąc te elementy pod uwagę postanowiłem zmienić podejście. Tym razem jedynym sprzętem za który jestem odpowiedzialny jest Rasberry Pi z zestawem czujników. Reszta to płatne usługi. Mimo tego, że usługi te są płatne, finalnie koszt będzie dużo niższy. Dokładny opis szacowania kosztów znajdziesz w jednym z punktów poniżej.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Kolejnym elementem, który zmieniłem jest stos technologiczny. Stara wersja Pogodynki używała głównie języka Java. Do tego przygotowałem prosty interfejs użytkownika przy pomocy HTML, CSS i JavaScript. Do zarządzania infrastrukturą użyłem Puppet’a.
Nowe podejście jest mocno uproszczone. Użyłem języka Python. Do zarządzania zasobami w chmurze posłużyłem się Terraform’em.
Przebudowując Pogodynkę rozszerzyłem ją o dodatkowy czujnik. Teraz Pogodynka może także mierzyć ilość pyłu zawieszonego o średnicy 2.5μm i 10μm. Podstawowa wersja kodu, którą napisałem sprawia, że czujnik działa bez przerwy. Pogodynka 2.1 mogłaby włączać czujnik na żądanie wydłużając żywotność lasera.
Co tu dużo mówić, większą frajdę sprawia mi pisanie kodu w Python’ie. Między innymi z tego powodu zdecydowałem, że Pogodynka 2.0 używa tego języka programowania. Proszę spójrz na przykład poniżej:
class SDS011:
DATA_PACKET_SIZE = 10
HEADER = 0xAA
TAIL = 0xAB
def __init__(self, port):
self.port = port
def poke_25(self):
data = self.read_bytes()
return int.from_bytes(data[2:4], byteorder="little") / 10
def poke_10(self):
data = self.read_bytes()
return int.from_bytes(data[4:6], byteorder="little") / 10
def read_bytes(self):
data = self.port.read(self.DATA_PACKET_SIZE)
assert data[0] == self.HEADER
assert data[9] == self.TAIL
return data
Te 24 linijek kodu to wszystko, co jest potrzebne do obsługi czujnika SDS011 (dla czytelności pominąłem dokumentację i komentarze). Aż boję się pomyśleć ile musiałbym się nadziubać, żeby zaimplementować to w Javie :). Testy jednostkowe dla kodu są równie zwięzłe:
@pytest.fixture
def port_mock():
port_mock = mock.MagicMock()
port_mock.read.return_value = b"\xaa\xc0\x1c\x001\x00\x0b\x141\xab"
return port_mock
def test_pm25(port_mock):
pm_sensor = sds011.SDS011(port_mock)
assert pm_sensor.poke_25() == pytest.approx(2.8)
def test_pm10(port_mock):
pm_sensor = sds011.SDS011(port_mock)
assert pm_sensor.poke_10() == pytest.approx(4.9)
Przeniosłem Pogodynkę do chmury :). Postawiłem na Google BigQuery jako bazę danych, w której przechowuję wyniki pomiarów. Przesiadka na BigQuery pozwoliła mi zwiększyć częstotliwość pomiarów bez przejmowania się potencjalnymi problemami z wydajnością bazy danych. Pogodynka 2.0 wysyła wskazania czujników co minutę.
Za strumieniowe przesyłanie danych do chmury odpowiedzialny jest poniższy fragment kodu:
def stream_to_gbq(client, scoped_table, measurements):
rows_to_insert = []
for measurement in measurements:
row = {
"time": measurement.time.strftime(GBQ_TIMESTAMP_FORMAT),
"pm25": measurement.pm25,
"pm10": measurement.pm10,
"temperature": measurement.temperature,
}
rows_to_insert.append(row)
try:
errors = client.insert_rows_json(scoped_table, rows_to_insert)
except exceptions.BadRequest as e:
raise StoreError(e)
if errors != []:
raise StoreError(errors)
Zrezygnowałem też z własnego narzędzia do wizualizacji na rzecz Google DataStudio. Przykładowy raport wygenerowany na podstawie danych zebranych przez Pogodynkę 2.0 możesz zobaczyć poniżej:
Starałem się maksymalnie ograniczyć liczbę komponentów żeby niepotrzebnie nie komplikować rozwiązania.
W związku z przesiadką na chmurę odstawiłem w kąt Puppet’a. Tym razem użyłem Terraform’a do zarządzania wszystkimi zasobami w chmurze. Przechowywanie definicji infrastruktury jako kod ma wiele zalet. Jedną z nich jest możliwość wglądu w historię zmian. Konfiguracja w przypadku Pogodynki 2.0 nie jest skomplikowana. Możesz ją przejrzeć w publicznym repozytorium.
Przy Pogodynce 1.0 zauważyłem, że elementem, który był najbardziej zawodny był dostęp do internetu w miejscu gdzie działała Pogodynka. Skutkowało to utratą wyników pomiarów wykonanych w czasie, kiedy nie było dostępu do internetu.
Tym razem postanowiłem to zmienić wprowadzając lokalny cache. W przypadku braku dostępu do internetu Pogodynka 2.0 zapisuje wyniki pomiarów lokalnie. Raz jeszcze dzięki Python’owi cały kod jest niesłychanie zwięzły:
class Cache:
def __init__(self, cache_path):
self.cache_path = cache_path
def load(self):
if not path.isfile(self.cache_path):
return []
with open(self.cache_path, "rb") as cache_file:
return pickle.load(cache_file)
def dump(self, measurements):
with open(self.cache_path, "wb") as cache_file:
pickle.dump(measurements, cache_file)
def clear(self):
self.dump([])
Połączenie klas wspomnianych wyżej możesz zobaczyć poniżej:
pm_port = serial.Serial(args.pm_sensor_device)
pm_sensor = sds011.SDS011(pm_port)
temperature_sensor = ds18b20.DS18B20(args.temperature_sensor_path)
cache = store.Cache(args.cache_path)
measurements = cache.load()
measurements.append(
sensor.Measurement(
time=datetime.datetime.utcnow(),
pm25=pm_sensor.poke_25(),
pm10=pm_sensor.poke_10(),
temperature=temperature_sensor.poke(),
),
)
gbq_client = bigquery.Client()
try:
store.stream_to_gbq(gbq_client, args.destination_table, measurements)
cache.clear()
except store.StoreError:
cache.dump(measurements)
raise
Na początku tworzę instancje klas obsługujących czujniki. Kolejna linijka to utworzenie instancji klasy obsługującej lokalny cache. Następne linijki to utworzenie listy pomiarów do wysłania.
W końcu wysyłam pomiary do tabeli w Google BigQuery.
System, które używałem w przypadku Pogodynki 1.0 ma już swoje lata. Zdecydowałem się zaktualizować go do najnowszej wersji. Ze strony Rasberry Pi pobrałem najnowszą wersję systemu Raspberry Pi OS Lite. Obraz skopiowałem na kartę microSD zgodnie z instrukcją ze strony Raspberry Pi.
Po uruchomieniu malinki zaktualizowałem zainstalowane oprogramowanie i dodałem kilka dodatkowych narzędzi:
apt-get update
apt-get upgrade
apt-get install python3-pip python3-venv git vim tree
Kod odpowiedzialny za Pogodynkę uruchamiany jest w kontekście dedykowanego użytkownika:
useradd pogodynka
usermod --append --groups dialout pogodynka
Ostatnia komenda dodaje użytkownika pogodynka
do grupy dialout
. Jest to potrzebne, aby mieć bezpośredni dostęp do odczytu danych z portu USB.
Wszystkie te operacje wykonałem podłączając klawiaturę bezpośrednio do malinki. Dla wygody włączyłem demona ssh, dzięki czemu pozostałe operacje mogę wykonać zdalnie:
touch /boot/ssh
Żeby termometr działał poprawnie wymagana jest drobna modyfikacja konfiguracji malinki:
echo dtoverlay=w1-gpio >> /boot/config.txt
reboot
modprobe w1-gpio
modprobe w1-therm
Pogodynka 1.0 używała serwera współdzielonego. Korzystałem z usług jednej z polskich firm hostingowych. Koszt utrzymania serwera wynosił około 10zł miesięcznie. Pogodynka 2.0 to zupełnie inna para kaloszy. Szacowanie kosztów usług chmurowych jest dużo bardziej złożone. W przypadku usług, z których korzystam składniki ceny są następujące:
Spróbuję oszacować wielkość bazy danych po roku:
60 (odczytów na godzinę) * 24 (godziny) * 365 (dni) = 525'600 (odczytów rocznie)
Każdy odczyt to jeden wiersz. Tabela przechowująca pomiary składa się z czterech kolumn:
DATETIME
FLOAT
FLOAT
FLOAT
Każda z tych kolumn to 8B, więc jeden wiersz to 8B * 4 = 32B. Zatem 525’600 odczytów rocznie to w sumie 17MB (16’819’200B). Zatem przechowywanie całego roku danych kosztuje aż $0,00034. A… zapomniałem dodać, że pierwsze 10GB jest darmowe. Innymi słowy przy takiej skali danych nie muszę się przejmować opłatami za przechowywanie danych.
Zdecydowałem się na ładowanie strumieniowe żeby mieć natychmiastowy dostęp do danych. Strumieniowe ładowanie danych jest płatne. W ciągu miesiąca Pogodynka 2.0 doda do bazy następującą liczbę wierszy:
60 (odczytów na godzinę) * 24 (godziny) * 30 (dni) = 43'200 (odczytów miesięcznie)
Żądanie dodania danych do bazy ma minimum 1KB (w przypadku Pogodynki 2.0 jest dużo mniejsze, jednak takie jest ograniczenie narzucone przez Google BigQuery). Zatem w ciągu miesiąca strumieniowo zostanie przesłanych 43,2MB danych. Podsumowując, miesięczny koszt strumieniowego ładowania danych to $0,0002. Myślę, że mogę żyć z takim zobowiązaniem ;).
Nie planuję odczytywać danych samodzielnie. Dane będą odczytywane przez raport, który utworzyłem w DataStudio. Dla uproszczenia pomijam tu kwestię przechowania wyników w cache’u, która obniży finalny koszt.
Załóżmy, że pogodynka będzie działała przez 1000 lat ;). W trakcie tak długiego czasu w bazie uzbiera się 17GB. Jednorazowy odczyt tysiącletniej historii pomiarów czujników Pogodynki 2.0 kosztowałby $0,09. A… znów zapomniałem o tym, że pierwszy 1TB w miesiącu jest darmowy. Po raz kolejny przy takiej skali danych nie muszę się przejmować opłatami za odczyt danych z bazy.
W tym konkretnym przypadku chmura jest praktycznie darmowa. Pamiętaj jednak, że podobną analizę kosztów warto zrobić dla konkretnego przypadku – koszty rozwiązań chmurowych potrafią zaskoczyć, jeśli projektowane rozwiązania nie są efektywne.
Dla przykładu, w pierwotnej wersji Pogodynki 2.0 każdy czujnik zapisywał pomiar w osobnym wierszu. Sprowadzał się do do trzy razy większej liczby wierszy – trzy razy wyższym koszcie za strumieniowe przesyłanie danych. W skali Pogodynki $0,0002 czy $0,0006 nie robi większej różnicy, jednak w produkcyjnych systemach operujących na dużych zbiorach danych takie szczegóły mogą być bardzo istotne.
Pracując z projektami opartymi o chmurę miałem do czynienia z różnym podejściem do zarządzania zasobami w chmurze. W części z projektów zasoby były tworzone ręcznie. Całość konfiguracji odbywała się ręcznie przez interfejs dostarczony przez dostawcę chmury.
Na dłuższą metę takie podejście jest uciążliwe. Używanie narzędzi pokroju Terraform znacząco ułatwia pracę w projekcie opartym o chmurę.
Pogodynka 2.0 już działa. Zachęcam Cię do samodzielnego wykonania takiego projektu. Koszt jaki będziesz musiał ponieść jest znikomy w porównaniu do wiedzy, którą możesz zdobyć. Jedynym znaczącym kosztem jest cena samej malinki (aktualnie około 180zł) i czujnika SDS011 (aktualnie około 70zł).
Kod źródłowy Pogodynki 2.0 dostępny jest na Samouczkowym Githubie.
Mam nadzieję, że artykuł był dla Ciebie pomocny. Proszę podziel się nim ze swoimi znajomymi. Dzięki temu pozwolisz mi dotrzeć do nowych Czytelników, za co z góry dziękuję. Jeśli nie chcesz pominąć kolejnych artykułów dopisz się do samouczkowego newslettera i polub Samouczka Programisty na Facebooku.
Do następnego razu!
Dla uproszczenia obliczeń pomijam tutaj tak zwane „long-term storage”, dane starsze niż 90 dni kosztują $0,01 za 1GB ↩
To jest jeden z artykułów w ramach praktycznego kursu SQL. Proszę zapoznaj się z pozostałymi częściami, mogą one być pomocne w zrozumieniu materiału z tego artykułu.
Każde zapytanie z kursu możesz wykonać samodzielnie. Potrzebujesz do tego środowiska opisanego w pierwszym artykule kursu. Bardzo mocno Cię do tego zachęcam. Moim zdaniem najwięcej nauczysz się samodzielnie eksperymentując z zapytaniami.
W tym artykule używam funkcji SQLite, które zostały dodane w wersji 3.28.0. Jeśli używasz SQLite do eksperymentowania upewnij się, że korzystasz z wersji 3.28.0 bądź nowszej. Możesz to zrobić używając polecenia sqlite3 --version
.
W jednym zdaniu można powiedzieć, że funkcje analityczne (ang. analytic functions) zwracają wartość na podstawie grupy wierszy powiązanych z aktualnym wierszem. Tę grupę nazywa się partycją. Sam opis może być skomplikowany, więc proszę spójrz na przykład poniżej:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (PARTITION BY customerid) AS customer_total_sum
FROM invoice
ORDER BY customerid
LIMIT 10;
Poza funkcją analityczną użyłem tu aliasu kolumny, sortowania i ograniczenia liczby zwracanych wierszy. W wyniku tego zapytania otrzymasz dziesięć wierszy:
CustomerId InvoiceId Total customer_total_sum
---------- --------- ----- ------------------
1 98 3.98 39.62
1 121 3.96 39.62
1 143 5.94 39.62
1 195 0.99 39.62
1 316 1.98 39.62
1 327 13.86 39.62
1 382 8.91 39.62
2 1 1.98 37.62
2 12 13.86 37.62
2 67 8.91 37.62
To zapytanie zwraca cztery różne kolumny. Ostatnia z nich jest wynikiem działania funkcji analitycznej. Spróbuję rozłożyć ją na części pierwsze:
SUM(total)
OVER
(PARTITION BY customerid)
W pierwszej linijce widzisz funkcję SUM
. Możesz ją pamiętać z artykułu o funkcjach w SQL. W poprzednim przypadku była ona użyta jako funkcja agregująca. Użycie słowa kluczowego OVER
sprawia, że jej zachowanie nieznacznie się zmienia. W tym przypadku SUM
nadal zwraca sumę, jednak w przypadku funkcji analitycznej pod uwagę brana jest partycja a nie cała tabela1.
W ostatniej linijce znajduje się definicja partycji, która zostanie użyta do obliczenia wartości funkcji. W tym przypadku do partycji należą wiersze zawierające taką samą wartość kolumny customerid
.
Zatem ta funkcja:
total
(SUM(total)
),customerid
(PARTITION BY customerid
).Proszę spójrz na pierwszych siedem wierszy, które mają taką samą wartość kolumny customerid
. Kolumna total
sumowana jest w ramach partycji: 3.98 + 3.96 + 5.94 + 0.99 + 1.98 + 13.86 + 8.91 = 39.62
. Wartość ta, będąca wynikiem działania funkcji, jest przypisywana do każdego wiersza z partycji.
Można powiedzieć, że funkcje analityczne są podobne do standardowego grupowania przy użyciu klauzuli GROUP BY
. Funkcje agregujące zwracają jeden wiersz dla grupy, funkcje analityczne zwracają wiele wierszy.
Poniżej znajdziesz listę funkcji agregujących, których możesz użyć przed słowem kluczowym OVER
:
AVG
– zwraca średnią wartość,COUNT
– zwraca liczbę wierszy,MAX
– zwraca maksymalną wartość,MIN
– zwraca minimalną wartość,SUM
– zwraca sumę wartości.Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
PARTITION BY
W przykładzie wyżej wszystkie wiersze w tabeli invoice
zostały podzielone na osobne partycje. Do podziału na partycje użyłem wyłącznie jednej kolumny. W klauzuli PARTITION BY
możesz użyć wielu wyrażeń:
SUM(total) OVER
OVER
(PARTITION BY customerid, billingcountry)
W tym przypadku tabela zostanie podzielona na więcej partycji. Do jednej partycji trafią wszystkie wiersze, które mają taką samą wartość kolumn customerid
i billingcountry
.
Istnieje też możliwość pominięcia klauzuli PARTITION BY
:
SUM(total) OVER ()
W takim przypadku partycja równoznaczna jest z całą tabelą2. Dla każdego wynikowego wiersza SUM(total) OVER ()
zwróci sumę kolumny total
we wszystkich wierszach.
Funkcje analityczne mogą być użyte wyłącznie w klauzuli SELECT
i ORDER BY
. Wynika to z faktu, że funkcje analityczne operują na „wirtualnej tabeli” (w modelu relacyjnym można mówić o relacji), która powstanie po filtrowaniu i grupowaniu wierszy.
Można powiedzieć, że zapytanie wykonywane jest w następującej kolejności:
WHERE
,GROUP BY
,HAVING
,ORDER BY
,LIMIT
.Tak naprawdę, to funkcja do obliczenia wartości bierze pod uwagę tak zwane okno. Każdy wiersz w partycji ma swoje własne okno, które jest podzbiorem partycji. Jeśli okno nie jest zdefiniowane wówczas przyjmuje ono wartość całej partycji. Istnieje wiele możliwości na ograniczenie okna dla funkcji analitycznej. Najprostszym z nich jest użycie klauzuli ORDER BY
.
Teraz czas na Twoje eksperymenty. Spróbuj samodzielnie uruchomić przykładowe zapytanie. Możesz je także zmodyfikować:
customerid
.Nieznacznie zmodyfikuję definicję partycji z pierwszego zapytania. Przykład poniżej używa dwóch funkcji. Druga z nich używa ORDER BY invoiceid
po definicji partycji:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (PARTITION BY customerid) AS customer_total_sum
,SUM(total) OVER (PARTITION BY customerid
ORDER BY invoiceid) AS customer_total_increasing_sum
FROM invoice
ORDER BY customerid, invoiceid
LIMIT 10;
Proszę spójrz na wynik zapytania. Zwróć uwagę na wartości kolumn customer_total_sum
i customer_total_increasing_sum
:
CustomerId InvoiceId Total customer_total_sum customer_total_increasing_sum
---------- --------- ----- ------------------ -----------------------------
1 98 3.98 39.62 3.98
1 121 3.96 39.62 7.94
1 143 5.94 39.62 13.88
1 195 0.99 39.62 14.87
1 316 1.98 39.62 16.85
1 327 13.86 39.62 30.71
1 382 8.91 39.62 39.62
2 1 1.98 37.62 1.98
2 12 13.86 37.62 15.84
2 67 8.91 37.62 24.75
Użycie ORDER BY
w definicji funkcji analitycznej powoduje zmianę okna dla każdego z wierszy. ORDER BY
tworzy „narastające okna” dla każdego kolejnego wiersza:
3.98 = 3.98
),3.98 + 3.96 = 7.94
),3.98 + 3.96 + 5.94 = 13.88
),Zauważ, że w tym przykładzie użyłem dwóch klauzul ORDER BY
. Pierwsza z nich służy do określenia okna dla funkcji analitycznej, druga służy do sortowania wyników całego zapytania.
Zapytanie używające partycji zwraca dane posortowane zgodnie z definicją partycji. Na przykład wyniki poniższego zapytania będą posortowane używając kolumny customerid
:
SELECT customerid
,SUM(total) OVER (PARTITION BY customerid) AS customer_total_sum
FROM invoice
Chociaż dane będą zwrócone w ten sposób nie polegałbym na tym zachowaniu. Jeśli zależy Ci na uzyskaniu posortowanych danych określ to jasno używając klauzuli ORDER BY
. W ten sposób jasno określasz swoje intencje:
SELECT customerid
,SUM(total) OVER (PARTITION BY customerid) AS customer_total_sum
FROM invoice
ORDER BY customerid;
We wszystkich przykładach w artykule dodałem klauzulę ORDER BY
.
Teraz czas na Twoje eksperymenty. Spróbuj samodzielnie uruchomić przykładowe zapytanie zawierające dwie funkcje analityczne. Możesz je także zmodyfikować:
DESC
/ASC
do z zmiany wyniku sortowania.Wyobraź sobie sytuację, w której chcesz zwrócić wynik różnych funkcji analitycznych, jednak używając tej samej definicji partycji. Spójrz na przykład poniżej:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (PARTITION BY customerid) AS customer_total_sum
,AVG(total) OVER (PARTITION BY customerid) AS customer_total_avg
FROM invoice
ORDER BY customerid
LIMIT 10;
W tym przykładzie definicja partycji jest prosta. Możesz jednak trafić na przypadek, w którym będzie ona dużo bardziej skomplikowana. Takie zapytanie zawiera duplikację definicji partycji. Duplikacja w większości przypadków jest zła. Nie inaczej jest w przypadku zapytań SQL. W takiej sytuacji z pomocą przychodzi klauzula WINDOW
. Proszę spójrz na przykład poniżej, jest on równoznaczny z poprzednim zapytaniem:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER customer_window AS customer_total_sum
,AVG(total) OVER customer_window AS customer_total_avg
FROM invoice
WINDOW customer_window AS (PARTITION BY customerid)
ORDER BY customer id
LIMIT 10;
Oba zapytania zwrócą ten sam wynik:
CustomerId InvoiceId Total customer_total_sum customer_total_avg
---------- --------- ----- ------------------ ------------------
1 98 3.98 39.62 5.66
1 121 3.96 39.62 5.66
1 143 5.94 39.62 5.66
1 195 0.99 39.62 5.66
1 316 1.98 39.62 5.66
1 327 13.86 39.62 5.66
1 382 8.91 39.62 5.66
2 1 1.98 37.62 5.37428571428571
2 12 13.86 37.62 5.37428571428571
2 67 8.91 37.62 5.37428571428571
Co więcej partycje zdefiniowane w ten sposób możesz dodatkowo rozszerzać:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (customer_window ORDER BY invoiceid) AS customer_ordered_total_sum
,AVG(total) OVER customer_window AS customer_total_avg
FROM invoice
WINDOW customer_window AS (PARTITION BY customerid)
ORDER BY customerid
LIMIT 10;
W tym przykładzie suma kolumny total
jest narastająca:
CustomerId InvoiceId Total customer_ordered_total_sum customer_total_avg
---------- --------- ----- -------------------------- ------------------
1 98 3.98 3.98 5.66
1 121 3.96 7.94 5.66
1 143 5.94 13.88 5.66
1 195 0.99 14.87 5.66
1 316 1.98 16.85 5.66
1 327 13.86 30.71 5.66
1 382 8.91 39.62 5.66
2 1 1.98 1.98 5.37428571428571
2 12 13.86 15.84 5.37428571428571
2 67 8.91 24.75 5.37428571428571
Jak już wiesz funkcje analityczne działają w oparciu o partycje. Dodatkowo funkcje te pozwalają Ci na zdefiniowanie tak zwanego okna. Domyślnie okno zawiera:
ORDER BY
,ORDER BY
.Domyślną zawartość okna możesz zmienić. Okno pozwala na dalsze ograniczenie wierszy branych pod uwagę przez funkcję. Składnię można rozszerzyć do:
<funkcja>
OVER
[ PARTITION BY … ]
[ ORDER BY … ]
<definicja okna>
Okno może być jednego z trzech rodzajów:
ROWS
– granice okna określone są przez liczbę wierszy przed i po aktualnym wierszu,GROUPS
– granice okna określone są przez liczbę „grup” przed i po aktualnej „grupie”. Do grupy zalicza się te wartości, które są „równe” w trakcie sortowania przy użyciu ORDER BY
,RANGE
– granice okna określone są przez różnicę wartości względem aktualnego wiersza.Dla uproszczenia w definicji okna będę używał wyłącznie BETWEEN x PRECEDING AND y FOLLOWING
. Oznacza to, że okno będzie obejmowało zakres x
przed aktualnym wierszem i y
po aktualnym wierszu. Składania pozwala na dużo bardziej zaawansowane modyfikacje, jednak ich znajomość nie jest niezbędna do zrozumienia działania samego mechanizmu. Jeśli jesteś zainteresowany tymi szczegółami odsyłam Cię do dokumentacji silnika bazy danych, którego używasz.
Mam świadomość, że to wszystko brzmi jak łacina bez konkretnego przykładu. Postaram się to poprawić ;)
ROWS
Proszę spójrz na pierwszy z nich:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (PARTITION BY customerid
ORDER BY invoiceid
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS rolling_sum
FROM invoice
ORDER BY customerid
,invoiceid
LIMIT 10;
W wyniku tego zapytania otrzymasz 10 wierszy:
CustomerId InvoiceId Total rolling_sum
---------- --------- ----- -----------
1 98 3.98 7.94
1 121 3.96 13.88
1 143 5.94 10.89
1 195 0.99 8.91
1 316 1.98 16.83
1 327 13.86 24.75
1 382 8.91 22.77
2 1 1.98 15.84
2 12 13.86 24.75
2 67 8.91 24.75
W tym przypadku SUM(total)
sumuje jedynie wiersze należące do okna, a nie całej partycji.
3.98 + 3.96 = 7.94
(brak poprzedniego wiersza w partycji),3.98 + 3.96 + 5.94 = 13.88
,13.86 + 8.91 = 22.77
(brak następnego wiersza w partycji).GROUPS
Tym razem do utworzenia partycji posłużę się kolumną billingcountry
:
SELECT billingcountry
,invoiceid
,total
,SUM(total) OVER (PARTITION BY billingcountry
ORDER BY total
GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS rolling_sum
FROM invoice
WHERE billingcountry = 'India'
ORDER BY total;
W wyniku tego zapytania otrzymasz 13 wierszy:
BillingCountry InvoiceId Total rolling_sum
-------------- --------- ----- -----------
India 120 1.98 9.92
India 218 1.98 9.92
India 315 1.98 9.92
India 97 1.99 17.84
India 412 1.99 17.84
India 23 3.96 23.78
India 338 3.96 23.78
India 45 5.94 37.62
India 360 5.94 37.62
India 186 8.91 57.42
India 284 8.91 57.42
India 131 13.86 45.54
India 229 13.86 45.54
Także tym przypadku SUM(total)
sumuje jedynie wiersze należące do okna, a nie całej partycji:
3 * 1.98 + 2 * 1.99 = 9.92
(brak poprzedniej grupy w partycji),3 * 1.98 + 2 * 1.99 + 2 * 3.96 = 17.84
,2 * 8.91 + 2 * 13.86
(brak następnej grupy w partycji).RANGE
W tym przypadku okno definiowane jest jako „odległość” 2 przed i po wartości kolumny total
:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER (PARTITION BY customerid
ORDER BY total
RANGE BETWEEN 2 PRECEDING AND 2 FOLLOWING) AS rolling_sum
FROM invoice
ORDER BY customerid
,total
LIMIT 10;
W wyniku tego zapytania otrzymasz dziesięć wierszy:
CustomerId InvoiceId Total rolling_sum
---------- --------- ----- -----------
1 195 0.99 2.97
1 316 1.98 10.91
1 121 3.96 15.86
1 98 3.98 15.86
1 143 5.94 13.88
1 382 8.91 8.91
1 327 13.86 13.86
2 293 0.99 4.95
2 1 1.98 8.91
2 196 1.98 8.91
Także tutaj SUM(total)
sumuje jedynie wiersze należące do okna, a nie całej partycji.
total
dla tych wierszy jest w zakresie <0.99 - 2, 0.99 + 2>
,total
dla tych wierszy jest w zakresie <1.98 - 2, 1.98 + 2>
,total
dla tych wierszy jest w zakresie <3.96 - 2, 3.96 + 2>
.Jakby tego było mało do tego wszystkiego dochodzi możliwość filtrowania :). Oznacza to tyle, że możesz użyć filtrowania jak w klauzuli WHERE
, żeby dodatkowo ograniczyć wiersze „pasujące” do definicji okna. Proszę spójrz na przykład poniżej:
SELECT customerid
,invoiceid
,total
,SUM(total) OVER rows_window AS rolling_sum
,SUM(total) FILTER (WHERE invoiceid != 121)
OVER rows_window AS filtered_rolling_sum
FROM invoice
WINDOW rows_window AS (PARTITION BY customerid)
ORDER BY customerid
LIMIT 10;
Zwróć uwagę na wartości kolumn rolling_sum
i filtered_rolling_sum
:
CustomerId InvoiceId Total rolling_sum filtered_rolling_sum
---------- --------- ----- ----------- --------------------
1 98 3.98 39.62 35.66
1 121 3.96 39.62 35.66
1 143 5.94 39.62 35.66
1 195 0.99 39.62 35.66
1 316 1.98 39.62 35.66
1 327 13.86 39.62 35.66
1 382 8.91 39.62 35.66
2 1 1.98 37.62 37.62
2 12 13.86 37.62 37.62
2 67 8.91 37.62 37.62
filtered_rolling_sum
ma wartość 39.62 - 3.96 = 35.66
. Zatem funkcja analityczna w przypadku partycji gdzie customerid = 1
nie wzięła pod uwagę filtrowanego wiersza. Wiersz, w którym invoiceid = 121
nie został wzięty pod uwagę podczas sumowania. Dla przypomnienia odsyłam cię do artykułu opisującego klauzulę WHERE
.
Bazy danych posiadają szereg funkcji dedykowanych do użycia z klauzulą OVER
. Poniżej znajdziesz listę zawierającą część z nich. Podobnie jak w innych przypadkach odsyłam Cię do dokumentacji Twojej bazy danych, jeśli chcesz znać ich komplet:
ROW_NUMBER()
– Numeruje wiersze w partycji zaczynając od 1. Bierze pod uwagę klauzulę ORDER BY
,RANK()
, DENSE_RANK()
– Funkcje numerujące unikalne wartości w partycji. RANK
zostawia „dziury” w numeracji. Pokażę to na przykładzie poniżej. Bez klauzuli ORDER BY
każdy z wierszy ma numer 1,NTILE(N)
– Dzieli partycję na N
„możliwie równych” i przydziela wiersze do grup o wartości od 1 do N
.Pierwszy przykład pokazuje działanie funkcji ROW_NUMBER
:
SELECT customerid
,total
,ROW_NUMBER() OVER (PARTITION BY customerid) AS row_number
FROM invoice
LIMIT 10;
CustomerId Total row_number
---------- ----- ----------
1 0.99 1
1 1.98 2
1 3.96 3
1 3.98 4
1 5.94 5
1 8.91 6
1 13.86 7
2 0.99 1
2 1.98 2
2 1.98 3
Drugi przykład porównuje funkcje RANK
i DENSE_RANK
. Proszę zwróć uwagę na wyniki tych funkcji dla 10. i 11. wiersza:
SELECT customerid
,total
,RANK() OVER customer_window AS rank_unsorted
,DENSE_RANK() OVER customer_window AS dense_rank_unsorted
,RANK() OVER (customer_window ORDER BY total) AS rank_sorted
,DENSE_RANK() OVER (customer_window ORDER BY total) AS dense_rank_sorted
FROM invoice
WINDOW customer_window AS (PARTITION BY customerid)
LIMIT 13;
CustomerId Total rank_unsorted dense_rank_unsorted rank_sorted dense_rank_sorted
---------- ----- ------------- ------------------- ----------- -----------------
1 0.99 1 1 1 1
1 1.98 1 1 2 2
1 3.96 1 1 3 3
1 3.98 1 1 4 4
1 5.94 1 1 5 5
1 8.91 1 1 6 6
1 13.86 1 1 7 7
2 0.99 1 1 1 1
2 1.98 1 1 2 2
2 1.98 1 1 2 2
2 3.96 1 1 4 3
2 5.94 1 1 5 4
2 8.91 1 1 6 5
Ostatni przykład pokazuje sposób podziału partycji przez funkcję NTILE
z użyciem różnych argumentów:
SELECT customerid
,total
,NTILE(2) OVER customer_window AS ntile_2
,NTILE(4) OVER customer_window AS ntile_4
FROM invoice
WINDOW customer_window AS (PARTITION BY customerid)
LIMIT 10;
CustomerId Total ntile_2 ntile_4
---------- ----- ------- -------
1 3.98 1 1
1 3.96 1 1
1 5.94 1 2
1 0.99 1 2
1 1.98 2 3
1 13.86 2 3
1 8.91 2 4
2 1.98 1 1
2 13.86 1 1
2 8.91 1 2
WHERE
Jak już wiesz funkcje analityczne mogą być użyte wyłącznie w klauzuli SELECT
i ORDER BY
. Co jeśli musisz użyć wyniku funkcji analitycznej do filtrowania? Z pomocą przychodzą podzapytania. Na przykład poniższe zapytanie zwróci wyłącznie te faktury wystawione dla klienta, których suma będzie mniejsza niż 10:
SELECT customerid
,invoiceid
,total
FROM invoice JOIN (SELECT invoiceid
,SUM(total) OVER (PARTITION BY customerid
ORDER BY invoiceid) AS invoice_sum
FROM invoice)
USING (invoiceid)
WHERE invoice_sum < 10
ORDER BY customerid
LIMIT 10;
CustomerId InvoiceId Total
---------- --------- -----
1 98 3.98
1 121 3.96
2 1 1.98
3 99 3.98
4 2 3.96
4 24 5.94
5 77 1.98
5 100 3.96
6 46 8.91
7 78 1.98
Nie przejmuj się, jeśli to zapytanie będzie dla Ciebie zbyt skomplikowane. To nic dziwnego, używa ono wielu elementów składki SQL. Postaraj się przeanalizować je jeszcze raz. Spróbuj też samodzielnie eksperymentować. Zacznij od wywołania podzapytania i przeanalizowania jego wyników.
Artykuł nie wyczerpuje tematu funkcji analitycznych. Zachęcam Cię do rzucenia okiem na dodatkowe materiały do nauki. Pamiętaj, że dokumentacja Twojego silnika baz danych jest niezastąpiona ;) i zawiera dużo bardziej szczegółowe informacje.
Po przeczytaniu tego artykułu wiesz już czym są funkcje analityczne. Wiesz czym takie funkcje różnią się od zwykłego grupowania. Wiesz czym są funkcje okna i jak ich używać. Po przerobieniu ćwiczeń możesz śmiało powiedzieć, że udało Ci się sprawdzić wiedzę w praktyce. Gratulacje ;), funkcje analityczne to jedne z bardziej zaawansowanych elementów składki SQL.
Mam nadzieję, że artykuł był dla Ciebie pomocny. Proszę podziel się nim ze swoimi znajomymi. Dzięki temu pozwolisz mi dotrzeć do nowych Czytelników, za co z góry dziękuję. Jeśli nie chcesz pominąć kolejnych artykułów dopisz się do samouczkowego newslettera i polub Samouczka Programisty na Facebooku.
Do następnego razu!
]]>Wyobraź sobie sytuację, w której prowadzisz sklep internetowy ze znaczkami pocztowymi. Obsługa zamówień odbywa się przez program, który zarządza całym procesem. Program nadzoruje wszystko od złożenia zamówienia do obsługi ewentualnych reklamacji. Jednym z etapów obsługi zamówienia jest wysyłka towaru do klienta.
Do tej pory program pozwalał wyłącznie na wysyłkę znaczków używając standardowej poczty. Z biegiem czasu klienci zaczęli oczekiwać dostępności innych sposobów dostawy. Problem polega na tym, że program używa wyłącznie jednego rodzaju wysyłki. Z pomocą w usprawnieniu takiego programu może przyjść metoda wytwórcza (ang. factory method).
W tym przypadku metoda wytwórcza może być odpowiedzialna za tworzenie klas odpowiedzialnych za różne rodzaje wysyłek.
Ten wzorzec projektowy w jednej ze swoich form opiera się o 4 elementy. Proszę spójrz na diagram klas poniżej:
Product
– klasa bazowa dla obiektów tworzonych przez metodę wytwórczą,Creator
– klasa zawierająca metodę wytwórczą factoryMethod
,SublassedProduct
– przykładowa podklasa Product
,SubclassedCreator
– podklasa, nadpisująca metodę wytwórczą zwracając instancję SubclassedProduct
.Chociaż na diagramie klas pokazałem Product
jako klasę, w rzeczywistości wcale nie musi tak być. Podobnie metoda factoryMethod
nie musi być abstrakcyjna.
Product
może być zdefiniowany jako interfejs. W takim przypadku podklasy Creator
tworzą instancje różnych klas implementujących interfejs Product
. Metoda factoryMethod
wcale nie musi być abstrakcyjna. Klasa Creator
może mieć domyślną implementację tej metody, która może być napisana przez podklasy.
Inną modyfikacją może być wprowadzenie parametrów do metody wytwórczej. W takim przypadku parametry mogą mieć wpływ na obiekt, który jest przez nią zwracany.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
W przykładzie odpowiednikiem Product
będzie następujący interfejs:
public interface DeliveryService {
void deliver(Parcel parcel);
}
Interfejs ten jest implementowany przez kilka klas. Jedną z nich możesz zobaczyć poniżej:
public class Pigeon implements DeliveryService {
@Override
public void deliver(Parcel parcel) {
System.out.println(String.format("Parcel %s was delivered by Pigeon", parcel));
}
}
Odpowiednikiem klasy Creator
jest klasa OrderLifecycle
, która obsługuje cykl życia zamówienia. Jak widzisz poniżej metoda wytwórcza zwraca instancję PostOffice
:
public class OrderLifecycle {
public void processOrder(String orderId) {
Parcel parcel = prepareParcel(orderId);
DeliveryService deliveryService = deliveryService();
deliveryService.deliver(parcel);
}
protected DeliveryService deliveryService() {
return new PostOffice();
}
private Parcel prepareParcel(String orderId) {
Parcel parcel = new Parcel(orderId);
System.out.println(String.format("Parcel %s was prepared", parcel));
return parcel;
}
}
Dodatkowe podklasy nadpisują implementację metody wytwórczej zwracając inną implementację interfejsu DeliveryService
:
public class PigeonOrderLifecycle extends OrderLifecycle {
@Override
protected DeliveryService deliveryService() {
return new Pigeon();
}
}
Przykładowa metoda main
pokazuje sposób wywołania poszczególnych klas, które używają metody wytwórczej:
public static void main(String[] args) {
CourierOrderLifecycle courierOrder = new CourierOrderLifecycle();
PigeonOrderLifecycle pigeonOrder = new PigeonOrderLifecycle();
OrderLifecycle postOfficeOrder = new OrderLifecycle();
postOfficeOrder.processOrder("order_1");
courierOrder.processOrder("order_2");
pigeonOrder.processOrder("order_3");
}
Po uruchomieniu tego programu na konsoli pokaże się:
Parcel [sampro:order_1] was prepared
Parcel [sampro:order_1] was delivered by PostOffice
Parcel [sampro:order_2] was prepared
Parcel [sampro:order_2] was delivered by Courier
Parcel [sampro:order_3] was prepared
Parcel [sampro:order_3] was delivered by Pigeon
Implementacja w języku Python wygląda trochę prościej1:
class PostOffice:
def deliver(self, parcel):
print(f"Parcel {parcel} was delivered by PostOffice")
class Courier:
def deliver(self, parcel):
print(f"Parcel {parcel} was delivered by Courier")
class Pigeon:
def deliver(self, parcel):
print(f"Parcel {parcel} was delivered by Pigeon")
class OrderLifecycle:
def process_order(self, order_id):
parcel = self.prepare_parcel(order_id)
delivery_service = self.delivery_service()
delivery_service.deliver(parcel)
def prepare_parcel(self, order_id):
parcel = f"[sampro:{order_id}]"
print(f"Parcel {parcel} was prepared")
return parcel
def delivery_service(self):
return PostOffice()
class PigeonOrderLifecycle(OrderLifecycle):
def delivery_service(self):
return Pigeon()
class CourierOrderLifecycle(OrderLifecycle):
def delivery_service(self):
return Courier()
if __name__ == "__main__":
courier_order = CourierOrderLifecycle()
pigeon_order = PigeonOrderLifecycle()
post_office_order = OrderLifecycle()
post_office_order.process_order("order_1")
courier_order.process_order("order_2")
pigeon_order.process_order("order_3")
Efekt działania tego programu będzie dokładnie taki sam jak w przypadku implementacji w języku Java.
Metoda wytwórcza to specyficzny przypadek innego wzorca projektowego – metody szablonowej. Wzorzec metody szablonowej opiszę w jednym z kolejnych artykułów w serii.
Metoda wytwórcza może być częścią innego wzorca projektowego jakim jest fabryka abstrakcyjna, także ten wzorzec omówię w jednym z kolejnych artykułów w serii.
Stosowanie metody wytwórczej sprawia, że kod staje się łatwiejszy do testowania. Dzieje się tak ponieważ w łatwy sposób można nadpisać metodę wytwórczą używając mock’ów, albo naiwnej implementacji na potrzeby testów.
To, że kod jest łatwiejszy do testowania jest konsekwencją stosowania reguł opisanych przez akronim SOLID:
Moim zdaniem główną wadą tego wzorca projektowego jest hierarchia dziedziczenia. Prowadzi ona do powstawania wielu (nadmiarowych?) bytów. Przeciążenie metody wytwórczej wymaga dziedziczenia po klasie, która ma już implementację tej metody. Pewną alternatywą dla takiego podejścia może być stosowanie kompozycji zamiast dziedziczenia. Proszę spójrz na przykład:
class OrderLifecycle:
def __init__(self, delivery_service_factory=PostOffice):
self.delivery_service_factory = delivery_service_factory
def process_order(self, order_id):
parcel = self.prepare_parcel(order_id)
delivery_service = self.delivery_service_factory()
delivery_service.deliver(parcel)
def prepare_parcel(self, order_id):
parcel = f"[sampro:{order_id}]"
print(f"Parcel {parcel} was prepared")
return parcel
if __name__ == "__main__":
courier_order = OrderLifecycle(Courier)
pigeon_order = OrderLifecycle(Pigeon)
post_office_order = OrderLifecycle()
post_office_order.process_order("order_1")
courier_order.process_order("order_2")
pigeon_order.process_order("order_3")
To rozwiązanie nie jest już „czystą” metodą wytwórczą. To coś pomiędzy budowniczym (tak, kolejny wzorzec, który opiszę w innym artykule) a metodą wytwórczą. Na byt tego typu czasami mówi się po prostu fabryka.
Ten wzorzec projektowy jest często używany w ramach fabryki abstrakcyjnej. Za przykład może to posłużyć metoda LogFactory.getLog
z biblioteki commons-logging.
Innymi przykładami mogą być metody w fabrykach związanych z obsługą formatu JSON, na przykład JsonReaderFactory
czy JsonBuilderFactory
.
W sekcji opisującej wady metody wytwórczej pokazałem sposób modyfikacji tego wzorca projektowego. Zaimplementuj analogiczne rozwiązanie w języku Java. Spróbuj użyć wyrażeń lambda. Przydatny może też być interfejs Supplier
.
Niezmiennie, we wszystkich artykułach z serii poświęconej wzorcom projektowym polecam książkę Design Patterns – Gamma, Helm, Johnson, Vlissides. Jeśli miałbym polecić wyłącznie jedno źródło to poprzestałbym na tej książce.
Warto także rzucić okiem do polskiej i angielskiej Wikipedii, gdzie znajdziesz artykuły opisujące metodę wytwórczą:
Kod źródłowy przykładów użytych w artykule także może być pomocny:
Wiesz już czym jest metoda wytwórcza i jak można ją zbudować. Znasz przykłady jej zastosowania zarówno z przykładu w artykule jak i innych bibliotek. Poznałeś zalety i wady tego wzorca projektowego. Wiesz jak można poradzić sobie z jego wadami. Jeśli udało Ci się samodzielnie rozwiązać zadanie do wykonania możesz śmiało powiedzieć, że znasz ten wzorzec projektowy. Gratulacje! :)
Jeśli artykuł przypadł Ci do gustu proszę podziel się nim ze znajomymi. Dzięki temu pozwolisz mi dotrzeć do nowych Czytelników, za co z góry dziękuję. Jeśli nie chcesz pomiąć kolejnych artykułów dopisz się do samouczkowego newslettera i polub Samouczka Programisty na Facebooku.
Do następnego razu!
Jeśli coś chodzi jak kaczka i kwacze jak kaczka to jest kaczką ;). W odróżnieniu od Javy nie stosowałem tu dziedziczenia w przypadku odpowiedników klasy Product
. Tę implementację można ją jeszcze uprościć, jak pokazałem paragrafie opisującym wady. ↩
Jeśli do tej pory nie pracowałeś z konsolą koniecznie przeczytaj artykuł opisujący początki pracy z linią poleceń. Mając podstawy opisane w tamtym artykule będzie Ci dużo łatwiej. Artykuł o początkach pracy z linią poleceń między innymi opisuje programy:
cd
ls
pwd
mkdir
rmdir
touch
echo
cat
clear
bash
‘aNa tym etapie wiesz już czym jest ścieżka. Sporo programów akceptuje ścieżki jako parametry. W niektórych przypadkach niezbędne jest przekazanie wielu ścieżek. W takiej sytuacji z pomocą mogą przyjść wyrażenia glob.
bash
nie wspiera wyrażeń regularnych. Mam na myśli to, że sama powłoka nie pozwala na przykład na określenia ścieżki do pliku używając wyrażeń regularnych1. bash
używa wyrażeń „glob”, które są do nich podobne.
Historycznie glob był osobnym programem, który został wchłonięty przez bash’a. Wyrażenia glob pozwalają na odwoływanie się do plików/katalogów używając ?
, *
i []
. Znak ?
zastępuje jeden znak, *
zastępuje dowolną liczbę znaków. Na przykład wyrażenie glob *.txt
pasuje do wszystkich plików z rozszerzeniem .txt
w aktualnym katalogu. Wyrażenie glob ?.txt
pasuje do wszystkich plików których nazwa (przed rozszerzeniem) ma jeden znak.
[]
zawiera w sobie grupę dozwolonych znaków. Na przykład wyrażenie [ab].txt
pasuje do nazw plików a.txt
i b.txt
ale nie pasuje do nazwy ab.txt
. Grupy umieszczone wewnątrz []
mogą być zakresami znaków. Zakres znaków oddzielony jest -
, na przykład [a-d].txt
pasuje do nazw plików a.txt
, b.txt
, c.txt
i d.txt
. Jeśli chcesz dopasować -
dosłownie umieść go na początku, albo na końcu grupy, na przykład [-a]
albo [a-]
.
Podsumowując, w wyrażeniach glob możesz używać następujących wzorców:
?
oznacza dowolny pojedynczy znak (poza /
i .
na początku)*
oznacza dowolną liczbę znaków (poza /
i .
na początku)[…]
oznacza grupę znaków zgodnie z zawartościąIstotne jest to, że wyrażenia glob są interpretowane przez konsolę zanim zostanie uruchomiony właściwy program. Proszę rzuć okiem na przykład poniżej:
$ ls
a.txt b.txt c.csv
$ ls *.txt
a.txt b.txt
W pierwszym przypadku zostanie uruchomiony program ls
bez żadnego parametru. Domyślnie zatem zostanie użyty aktualny katalog (.
). Program wypisze zawartość aktualnego katalogu, w moim przypadku są to trzy pliki: a.txt, b.txt i c.csv. W drugim przypadku pojawia się wyrażenie glob *.txt
, które zostaje rozwinięte przez konsolę do a.txt b.txt
i przekazane jako argument do programu ls
. Zatem w przykładzie powyżej ls *.txt
jest tak na prawdę wywołaniem ls a.txt b.txt
.
Wyrażenia glob nie biorą pod uwagę plików/katalogów, których nazwa zaczyna się od kropki (.
). Jeśli wyrażenie glob nie może być rozwinięte (nie pasuje do żadnego pliku/katalogu) zostanie przekazane jako parametr bez zmian:
$ ls
exists.txt
$ echo *.txt
exists.txt
$ echo *.pdf
*.pdf
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
~
W bash
‘u znak tyldy (~
) ma specjalne znaczenie. ~
oznacza katalog domowy użytkownika. Podobnie jak wyrażenia glob, tylda rozwijana jest do właściwej ścieżki przed przekazaniem jej jako parametr do programu. Proszę spójrz na przykład poniżej, w którym użyłem programu echo
:
$ echo ~
/home/mapi
$ echo ~/some/path
/home/mapi/some/path
Używając tyldy możesz także odwołać się do katalogu domowego dowolnego użytkownika. Na przykład ~root
oznacza katalog domowy użytkownika root
:
$ echo ~root
/root
Możesz użyć także rozwijania ~
do poznania aktualnego katalogu używając +
2:
$ cd /run/usr/1000
$ echo ~+
/run/user/1000
W podobny sposób -
pokazuje poprzedni katalog:
$ cd /tmp
$ cd
$ echo ~-
/tmp
Podobnie jak wyrażenia glob, także znak ~
jest rozwijany przez powłokę przed przekazaniem tego znaku jako parametr do uruchamianego programu.
{ }
Bash wspiera także mechanizm rozwijania { }
. Proszę spójrz na przykład poniżej:
$ echo some-{magic,long,complicated}-text
some-magic-text some-long-text some-complicated-text
Wywołanie programu echo
wyświetla przekazane argumenty używając standardowego wyjścia. Bash, w trakcie procesu rozwijania { }
zamienił pojedynczy parametr na trzy osobne parametry.
Wewnątrz nawiasów może znajdować się dowolna liczba elementów oddzielona znakiem ,
. Każdy z tych elementów będzie skutkował nowym „słowem” podstawionym przez bash’a.
Rozwijanie { }
może także służyć do generowania sekwencji numerów. Proszę spójrz na przykład, w którym generuję liczby od 7 do 10:
$ echo sequence-{7..10}
sequence-7 sequence-8 sequence-9 sequence-10
Użycie wiodącego 0
powoduje generowanie numerów o stałej szerokości:
$ echo sequence-{07..10}
sequence-07 sequence-08 sequence-09 sequence-10
Opcjonalnym, trzecim parametrem może być skok, który informuje o ile powinny różnić się kolejno generowane liczby:
$ echo sequence-{0..10..2}
sequence-0 sequence-2 sequence-4 sequence-6 sequence-8 sequence-10
Ten sam mechanizm można także użyć do generowania sekwencji liter:
$ echo sequence-{a..d}
sequence-a sequence-b sequence-c sequence-d
Najczęściej używam tej składni jeśli chcę skopiować albo przenieść plik czy folder:
$ ls
some_file.txt
$ mv some_file.txt{,.bak}
$ ls
some_file.txt.bak
bash
posiada zestaw parametrów, które mają specjalne znaczenie. Możesz odwołać się do tych parametrów używając składni $<znak parametru>
, na przykład $?
. Są one traktowane jako specjalne, ponieważ służą wyłącznie do odczytu. Część z nich znajdziesz poniżej:
$#
- zawiera liczbę parametrów przekazanych do skryptu bash’a$?
- zawiera kod wyjścia poprzednio uruchomionego programu$$
- zawiera identyfikator procesu bash’a$_
- zawiera ostatni argument poprzedniej komendyProszę spróbuj trochę poeksperymentować z użyciem tych parametrów, wtedy zrozumienie ich działania będzie dużo łatwiejsze.
Bash posiada bardzo przydatną funkcję, pozwala ona na zapisywanie historii wykonywanych poleceń. Przy odpowiedniej konfiguracji (domyślnej na przykład w Ubuntu) w pliku ~/.bash_history
zapisywana jest historia poleceń. Historia ta jest aktualizowana w momencie zamykania okna terminala.
Historia jest przydatna, bo często możesz używać poleceń, których używałeś poprzednio. Pomocny może być skrót klawiaturowy Ctrl+R
, który pozwala na przeszukiwanie historii. Po użyciu tego skrótu klawiaturowego zmieni się standardowy znak zachęty. Możesz wtedy wpisywać fragmenty poleceń z historii. Jak zwykle, zachęcam Cię do eksperymentów:
(reverse-i-search)`':
To dzięki historii możesz też używać strzałek (góra/dół) do poruszania się po historii wykonywanych poleceń. Chociaż sam używam częściej programu history
albo wspomnianego skrótu Ctrl+R
.
history
Program history
wypisuje historię wykonywanych poleceń. Często zdarza mi się używać tego programu w połączeniu z grep
i potokami:
$ history | grep docker | tail -n 3
4500 docker run --rm -it alpine
4501 docker run --rm -it --entrypoint /bin/sh alpine/helm
4545 history | grep docker | tail -n 3
Przydatny może być też program fc
, który pozwala na edycję wprowadzonych do tej pory komend przed ich wywołaniem. W przykładzie poniżej fc 1170
uruchomi edytor tekstu z poleceniem git rebase -i master
. To polecenie znajduje się na 1170 miejscu w historii bash’a:
$ history | grep git | tail -n 4
1170 git rebase -i master
1174 git status
1176 git log -5
1178 git push
$ fc 1170
Chociaż historia to dobra rzecz i nie raz może uratować skórę, zdarzają się przypadki, w których nie chcesz zostawiać po sobie śladu. Na przykład kiedy w linii poleceń wpisujesz hasło czy klucz do API.
To bardzo zła praktyka. Do przekazywania danych wrażliwych jak hasła czy tokeny dostępu używaj plików (przekazując ścieżkę do pliku z danymi wrażliwymi) albo zmiennych środowiskowych (zawierających dane wrażliwe albo ścieżkę do pliku z danymi wrażliwymi).
To rozwiązanie też nie jest idealne. Zmienne środowiskowe, podobnie jak pliki mogą być dostępne dla innych użytkowników systemu. Jednak takie rozwiązanie jest o niebo lepsze niż używanie danych wrażliwych bezpośrednio w konsoli.
W przypadku kiedy nie chcesz aby dana komenda została zapisana w historii poprzedź ją ` ` (spacją)3.
A co jeśli mleko już się rozlało i komenda została już zapisana w historii? Wówczas z pomocą przychodzi program history
z parametrem -d
:
$ history | tail -n 1
1190 curl https://admin:password1@ministry.gov
$ history -d 1190
$ history -w
Polecenie history -d 1190
usuwa z historii komendę z numerem 1190. hisory -w
zapisuje aktualną historię (z usuniętą komendą) w pliku historii.
Jeśli nie chcesz używać programu history
zawsze możesz edytować plik historii samodzielnie. Zmienna środowiskowa HISTFILE
przechowuje ścieżkę do pliku, w którym przechowywana jest historia poleceń:
$ vim $HISTFILE
Proszę spójrz na przykład poniżej, w którym użyłem podstawowego mechanizmu rozwijania historii:
$ history | grep git | tail -n 3
1174 git status
1176 git log -5
1178 git push
$ !1176
$ history | grep git | tail -n 3
1176 git log -5
1178 git push
1201 git log -5
Wywołanie !1176
spowodowało ponowne uruchomienie programu zapisanego w historii pod numerem 1176. Mechanizm ten jest dość rozbudowany. Jeśli chcesz poznać więcej jego możliwości odsyłam Cię do sekcji „History expansion” w dokumentacji bash
‘a.
Do tej pory używałem głównie określenia „program”, jednak nie we wszystkich przypadkach było to do końca poprawne. Dzieje się tak za sprawą poleceń wbudowanych.
W dochodzeniu do prawdy pomocny będzie program which
:). Ten program zwraca ścieżki programów, które byłyby uruchomione dla każdego z przekazanych parametrów. Robi to oparciu o listę katalogów przechowywanych w zmiennej środowiskowej PATH
. Proszę spójrz na przykład:
$ which ls
/bin/ls
W tym przykładzie which
zwraca absolutną ścieżkę programu, który zostanie uruchomiony po wywołaniu ls
. W tym przypadku jest to /bin/ls
.
W ten sam sposób możesz sprawdzić inne programy:
$ which which mount cron
/usr/bin/which
/bin/mount
/usr/sbin/cron
A teraz spróbuj zrobić to samo dla innych „programów”, których używasz cd
czy history
:
$ which cd history
Hmm ;), which
nie pokazało nic. Dzieje się tak z tego powodu, że zarówno cd
jak i history
to polecenia wbudowane w bash
‘a. Takich poleceń jest więcej. Jednym z wbudowanych poleceń jest type
, które rzuca więcej światła na tę sprawę:
$ type -a history
history is a shell builtin
Użyłem tu przełącznika -a
, który zwraca wszystkie możliwe opcje, a jest ich kilka :). Proszę spójrz na kolejny przykład:
$ type -a kill pwd
kill is a shell builtin
kill is /bin/kill
pwd is a shell builtin
pwd is /bin/pwd
Jak widzisz istnieją także „programy”, które są zarówno poleceniami wbudowanymi jak i zwyczajnymi programami. W dalszej części artykuł nadal będę używał określenia „program” odnosząc się zarówno do programów jak i poleceń wbudowanych.
Może się zdarzyć, że chcesz wywołać program, który działa bardzo długo a nie chcesz zajmować aktualnego okna konsoli. Z pomocą przychodzi operator &
:
$ ping www.samouczekprogramisty.pl > ~/ping_output.txt &
[1] 11410
W przykładzie powyżej wywołałem program ping
i przekierowałem standardowe wyjście do pliku ~/ping_output.txt
. Kolejna linia [1] 11410
informuje o tym, że zadanie [1]
działające w tle zostało uruchomione. Zadanie to działa jako proces 11410.
W każdym momencie możesz sprawdzić listę zadań używając programu jobs
:
$ jobs
[1]+ Running ping www.samouczekprogramisty.pl > ~/ping_output.txt &
W tym przypadku uruchomione jest jedno zadanie w tle, które ma status Running
. Możesz „przywołać” to zadanie używając programu fg
(od ang. foreground):
$ fg %1
ping www.samouczekprogramisty.pl > ~/ping_output.txt
W zarządzaniu zadaniami pomocny jest też skrót klawiaturowy <Ctrl+Z>
, który usypia aktualny program4:
$ fg %1
ping www.samouczekprogramisty.pl > ~/ping_output.txt
^Z # tu użyłem Ctrl+Z
[1]+ Stopped ping www.samouczekprogramisty.pl > ~/ping_output.txt
Jak widzisz w tym przypadku zadanie [1]
ma status Stopped
. Jeśli chcesz wznowić zatrzymany program w tle użyj programu bg
(od ang. background):
$ jobs
[1]+ Stopped ping www.samouczekprogramisty.pl > ~/ping_output.txt
$ bg %1
[1]+ ping www.samouczekprogramisty.pl > ~/ping_output.txt
$ jobs
[1]+ Running ping www.samouczekprogramisty.pl > ~/ping_output.txt &
Uruchomienie programu wiąże się z uruchomieniem procesu. Proces nadzorowany jest przez system operacyjny. Każdy proces posiada, między innymi, swój zestaw zmiennych środowiskowych.
Można powiedzieć, że zmienne środowiskowe są podobne do zmiennych w językach programowania. Zmienne środowiskowe zawierają dane, które dostępne są dla procesu (programu). Zazwyczaj nazwy zmiennych środowiskowych używają wielkich liter, choć nie jest to wymagane. Kilka przykładowych zmiennych środowiskowych:
PATH
– zawiera listę katalogów, w których poszukiwane są programy do uruchomienia. To dzięki tej zmiennej możesz napisać ls
bez podawania pełnej ścieżki programu (/bin/ls
),HOME
– zawiera ścieżkę do katalogu domowego użytkownika,EDITOR
– zawiera ścieżkę do preferowanego edytora tekstu,PPID
– zawiera identyfikator procesu nadrzędnego (tego, który uruchomił aktualny proces).Możesz sprawdzić aktualną listę zmiennych środowiskowych wywołując program set
bez żadnych parametrów5:
$ set | head -n 1
BASH=/bin/bash
Przykład poniżej pokazuje użycie zmiennych środowiskowych:
$ echo $HOME
/home/mapi
$ echo $HOMEsweetHOME
$ echo ${HOME}sweetHOME
/home/mapisweetHOME
Pierwsza komenda wyświetla zawartość zmiennej HOME
. Druga zawartość zmiennej HOMEsweetHOME
. Zauważ, że w tym przypadku bash
nie wie gdzie kończy się nazwa zmiennej środowiskowej. Dlatego właśnie wyświetla pustą linię – zmienna HOMEsweetHOME
nie jest zdefiniowana. W trzecim przypadku użyłem składni ${}
6 otaczając nawiasami klamrowymi nazwę zmiennej.
Możesz też definiować swoje zmienne środowiskowe używając składni NAZWA_ZMIENNEJ=wartosc zmiennej
:
$ echo $NEW_VARIABLE
$ NEW_VARIABLE="some value"
$ echo $NEW_VARIABLE
some value
Wiesz już, że zmienne środowiskowe przypisane są do procesu. Każdy proces ma swoją kopię zmiennych środowiskowych. Uruchamiając nowy proces eksportowane zmienne środowiskowe kopiowane są do procesu potomnego. Oznacza to tyle, że proces potomny ma dostęp wyłącznie do podzbioru zmiennych aktualnie zdefiniowanych.
Zmienną środowiskową możesz eksportować używając programu export
. Proszę spójrz na przykład:
$ VARIABLE_1=value1
$ export VARIABLE_2=value2
$ echo $VARIABLE_1 $VARIABLE_2 $PPID
value1 value2 2855
$ bash # uruchamia nowy proces
$ echo $VARIABLE_1 $VARIABLE_2 $PPID
value2 10189
W przykładzie możesz zobaczyć dwie zmienne: VARIABLE_1
i VARIABLE_2
. Druga z nich została wyeksportowana. Dzięki temu jest dostępne w procesie potomnym.
Podobnie jak w poprzednim artkule z serii jako pierwsze źródło polecę Ci dokumentację. Znów odsyłam cię do programu man
lub wbudowanej dokumentacji, którą możesz przeczytać uruchamiając <program> --help
.
W przypadku tego artykułu nieocenionym źródłem wiedzy będzie dokumentacja programu bash
, którą możesz przeczytać po uruchomieniu man bash
lub online.
Możesz też rzucić okiem na stronę https://explainshell.com, która pozwoli Ci lepiej zrozumieć bardziej skomplikowane komendy.
Niezmiennie zachęcam Cię do samodzielnych eksperymentów. Najwięcej nauczysz się samodzielnie bawiąc się linią poleceń.
Po lekturze tego artykułu możesz spokojnie używać linii poleceń w codziennej pracy. Udało Ci się poznać zestaw przydatnych cech bash
‘a. Potrafisz swobodnie poruszać się po historii poleceń i ją modyfikować w razie potrzeby. Wiesz więcej o zmiennych środowiskowych i rozumiesz jaka jest zależność pomiędzy procesem a zmienną środowiskową. Gratulacje! :)
To tyle na dzisiaj, dziękuję za lekturę, trzymaj się i do następnego razu! A… zapomniałbym, jeśli uważasz, że materiał może się przydać komuś z Twoich znajomych proszę podziel się z nim odnośnikiem do artykułu. W ten sposób pomożesz mi dotrzeć do nowych czytelników, z góry dziękuję! Jeśli nie chcesz pomiąć kolejnych artykułów dopisz się do samouczkowego newslettera i polub Samouczka Programisty na Facebooku.
Do następnego razu!
Zupełnie inną sprawą są programy, które pozwalają na używanie wyrażeń regularnych w przekazanych parametrach. ↩
Chociaż szczerze mówiąc częściej używam zmiennej środowiskowej PWD
lub wywołuję program pwd
;) ↩
Ten mechanizm zależy od wartości zmiennej środowiskowej HISTCONTROL
. ↩
Tak na prawdę to wysyła sygnał do procesu. To w jaki sposób ten sygnał jest obsłużony do inna sprawa. Domyślnie program jest „usypiany”. ↩
Program ten wyświetla też listę dostępnych funkcji. ↩
To mechanizm rozwijania parametrów, podobny do rozwijania ~
czy rozwijania {}
. ↩
Wyobraź sobie restaurację, w której możesz zjeść pizzę. Właściciel restauracji daje Ci do wyboru 10 różnych dodatków. Możesz skomponować pizzę samodzielnie używając dostępnych dodatków. Każdy z dodatków ma swoją cenę i może być użyty wyłącznie jeden raz. Właściciel restauracji mógłby wypisać wszystkie kombinacje z tych 10 dodatków. Menu miałoby wtedy 1023 pozycje, 1024 jeśli wliczymy Margharitę… Trochę dużo ;).
Właściciel podszedł do sprawy inaczej. Nadal daje Ci dowolność w wyborze dodatków, jednak wycenia każdy z nich jako osobną pizzę. Na przykład pizza z szynką, pizza z bazylią, pizza z mozzarellą i tak dalej. Następnie pozwala Ci łączyć ze sobą te pizze w dowolny sposób. Na przykład pizza bez żadnych dodatków kosztuje 15zł. Pizza z szynką kosztuje o 7 zł więcej niż pizza bazowa. Pizza z bazylią kosztuje o 2 zł więcej niż pizza bazowa.
Dzięki takiemu podejściu w menu znajduje się 11 pozycji. Cena pizzy bez dodatków i cena każdego dodatku określona jako cena pizzy bazowej + X zł. Można powiedzieć, że właściciel restauracji użył wzorca dekoratora do opracowania cennika1.
Podobne problemy występują w projektach informatycznych. Zdarzają się sytuacje, w których trzeba rozszerzyć działanie pewnego obiektu. Możliwych rozszerzeń jest wiele, jeszcze więcej jest kombinacji tych rozszerzeń. Z pomocą w rozwiązaniu tego problemu przychodzi wzorzec projektowy dekorator (ang. decorator2).
Istnieje wiele możliwości implementacji tego wzorca projektowego. Diagram klas poniżej pokazuje najprostszą z nich:
DecoratorA
i DecoratorB
dekorują klasę Component
. Dekoratory zawierają instancję klasy Component
.
Często ten wzorzec projektowy przedstawiany jest w bardziej skomplikowany sposób:
W tym przypadku dekoratory mają wspólnego przodka, abstrakcyjną klasę Decorator
. Sam komponent, który jest dekorowany także jest klasą abstrakcyjną, która posiada swoje konkretne implementacje. Na diagramie wyżej jest to ConcreteComponent
.
Nie są to jedyne możliwe wersje implementacji tego wzorca. Przykładem innej implementacji może być użycie interfejsów w miejscu klasy komponentu. Inną modyfikacją może być użycie kompozycji w miejscu agregacji. Obie zmiany nie wpływają znacząco na implementację tego wzorca projektowego.
Wzorzec projektowy dekorator pozwala na wielokrotne rozszerzenie funkcjonalności obiektu poprzez „nakładanie” na siebie dekoratorów.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Zacznę od pizzy bazowej:
public class Pizza {
private static final BigDecimal BASE_PRICE = new BigDecimal(12);
public BigDecimal getPrice() {
return BASE_PRICE;
}
@Override
public String toString() {
return "Pizza";
}
}
Ot, zwykła klasa, która reprezentuje podstawową pizzę. Posiada metodę getPrice
, która zwraca jej cenę.
Poniżej możesz zobaczyć jeden z dekoratorów. W tym przypadku jest to pizza z mozzarellą:
public class PizzaWithMozzarella extends Pizza {
private static final BigDecimal MOZZARELLA_PRICE = new BigDecimal(5);
private final Pizza basePizza;
public PizzaWithMozzarella(Pizza basePizza) {
this.basePizza = basePizza;
}
@Override
public BigDecimal getPrice() {
return basePizza.getPrice().add(MOZZARELLA_PRICE);
}
}
PizzaWithMozzarella
w konstruktorze przyjmuje jako parametr instancję klasy Pizza
, którą opakowuje. Następnie używa jej do obliczenia ceny pizzy z mozzarellą dodając do ceny pizzy bazowej cenę sera.
W tym przypadku klasa Pizza
odpowiada klasie Component
z diagramu UML, a klasa PizzaWithMozzarella
reprezentuje DecoratorA
.
Poniżej możesz zobaczyć użycie dekoratorów w praktyce. Opakowując kolejne pizze w dekoratory otrzymuję coraz bardziej skomplikowane pozycje. Dzięki takiemu podejściu mogę łączyć dodatki w dowolny sposób:
public class Restaurant {
public static void main(String[] args) {
Pizza margherita = new Pizza();
Pizza withMozzarella = new PizzaWithMozzarella(margherita);
Pizza withMozzarellaAndHam = new PizzaWithHam(withMozzarella);
Pizza withMozzarellaHamAndBasil = new PizzaWithBasil(withMozzarellaAndHam);
DecimalFormat df = new DecimalFormat("#,00 zł");
for (Pizza pizza : List.of(margherita, withMozzarella, withMozzarellaAndHam, withMozzarellaHamAndBasil)) {
System.out.println(String.format("%s costs %s.", pizza, df.format(pizza.getPrice())));
}
}
}
Jedną z często polecanych praktyk w programowaniu obiektowym jest preferowanie kompozycji przed dziedziczeniem. Wzorzec projektowy dekorator jest flagowym przykładem użycia tej reguły. Takie podejście pozwala na dynamiczne rozszerzanie funkcjonalności obiektu bez potrzeby kompilacji kodu.
Niewątpliwą zaletą dekoratora jest możliwość dowolnego łączenia istniejących dekoratorów. Każdy z nich będzie opakowywał kolejny obiekt nie mając świadomości, że jest kolejnym dekoratorem w kolejce. Jest to istotne w przypadku gdy istnieje kilka dodatkowych funkcjonalności, które powinna zawierać rozszerzana klasa.
Interfejs dekoratora musi być dokładnie taki sam jak klasy dekorowanej. W niektórych językach programowania (na przykład w Javie) może prowadzić to do klas, które mają sporo metod, których implementacja polega na przekazaniu wywołania do dekorowanego obiektu (jeśli dekorator implementuje interfejs). Tę wadę można rozwiązać stosując dziedziczenie3.
Dekorator często jest „płaską klasą”. Rozszerza on dekorowaną klasę o jedną, podstawową funkcjonalność. Prowadzić to może do sytuacji, w której system zawiera wiele niewielkich klas. W sytuacji gdy zazwyczaj używa się stałego zbioru dekoratorów użycie standardowego dziedziczenia może ograniczyć tę liczbę.
W przypadku języka Java wzorzec projektowy dekorator jest dość często używany w bibliotece standardowej. Za przykład mogą tu posłużyć strumienie wykorzystywane przy operacjach na plikach. InputStream
jest klasą abstrakcyjną, która posiada wiele dekoratorów, na przykład FileInputStream
czy BufferedInputStream
.
Innym przykładem, również z języka Java, mogą być dekoratory kolekcji. Dekoratory te na przykład pozwalają na utworzenie kolekcji, która jest synchronizowana czy niemodyfikowalna. Collections
zawiera szereg metod zaczynających się od synchronized
albo unmodifiable
, które tworzą instancje dekoratorów.
W języku Python istnieje składnia, która pozwala na łatwe użycie dekoratorów. Można powiedzieć, że ten wzorzec projektowy został wbudowany w język. Notacja @dekorator
pozwala dekorować zarówno klasy jak i funkcje. Przykładami dekoratorów dostępnych w bibliotece standardowej mogą być @property
, @contextlib.contextmanager
czy @functools.wraps
.
Chociaż klasy reprezentujące pizze z dodatkami spełniają swoje zadanie mogą być ulepszone. Zwróć uwagę, że klasy te są do siebie bardzo podobne. Duplikacja kodu jest zła, zrefaktoryzuj kod w taki sposób aby usunąć tę duplikację. Spróbuj rozwiązać ten problem używając bardziej skomplikowanej wersji dekoratorów z drugiego diagramu UML.
Jak zwykle zachęcam Cię do samodzielnego rozwiązania zadania, w ten sposób nauczysz się najwięcej. Możesz też porównać swoje rozwiązanie z przykładowym.
Niezmiennie, we wszystkich artykułach z serii poświęconej wzorcom projektowym polecam książkę Design Patterns – Gamma, Helm, Johnson, Vlissides. Jeśli miałbym polecić wyłącznie jedno źródło to poprzestałbym na tej książce.
Warto także rzucić okiem do polskiej i angielskiej Wikipedii gdzie znajdziesz artykuły dotyczące tego wzorca projektowego.
Zachęcam Cię też do zajrzenia do kodu źródłowego, którego użyłem w tym artykule.
Po lekturze tego artykułu wiesz czym jest wzorzec dekorator. Znasz przykładowy sposób jego implementacji. Masz też zestaw materiałów dodatkowych, które pozwolą Ci spojrzeć na temat z innej strony. Po rozwiązaniu zadania wiesz jak zaimplementować ten wzorzec samodzielnie. Innymi słowy udało Ci się właśnie poznać kolejny wzorzec projektowy. Gratulacje! ;)
Jeśli artykuł przypadł Ci do gustu proszę podziel się nim ze znajomymi. Dzięki temu pozwolisz mi dotrzeć do nowych Czytelników, za co z góry dziękuję. Jeśli nie chcesz pomiąć kolejnych artykułów dopisz się do samouczkowego newslettera i polub Samouczka Programisty na Facebooku.
Do następnego razu!
Ten przykład jest trochę naciągany. Sam dodatek nie jest pizzą, ale pizza z dodatkiem już tak. Jest to coś najbliższego światu rzeczywistemu co jest „dekoratorem” i powinno być łatwe do zrozumienia. ↩
Inną nazwą tego wzorca projektowego, z którą możesz się spotkać jest wrapper. ↩
Takie podejście może wydłużać hierarchię dziedziczenia, sam preferuję użycie interfejsów jeśli hierarchia dziedziczenia jest dość długa. ↩
Jak mówi znane powiedzenie „jeden obraz jest wart tysiąca słów”. Takie przypadki zdarzają się także w programowaniu. Często w trakcie projektowania czy rozmawiania na temat fragmentu oprogramowania programistom dużo łatwiej jest się porozumieć rysując. Takie rysunki mogą opisywać ogólną architekturę projektu, sposób podejścia do rozwiązania, kolejność zdarzeń w ramach procesu itd. Dobrze jest mieć wspólny język. W tym przypadku pomocny może być UML.
UML to akronim pochodzący od angielskiego określenia Unified Modeling Language. W polskim tłumaczeniu znany jest jako zunifikowany język modelowania. UML to jasno wyspecyfikowany język składający się z kilkunastu diagramów. Diagramy te pozwalają na formalne opisywanie i modelowanie struktur czy procesów.
Odpowiadając na tak postawione pytanie w jednym zdaniu mogę powiedzieć, że z mojego doświadczenia UML jest ważny i warto znać jego podstawy. Chociażby po to żeby rozszerzyć swój „słownik”, który później możemy użyć w trakcie rozmowy na temat programowania z inną osobą. UML to kolejne narzędzie, które możesz używać w odpowiednich sytuacjach. Rysunek, który usuwa zbędne szczegóły pokazując najbardziej istotne aspekty jest niezastąpiony.
Jednak to tylko część rzeczywistości. UML jest ważny, między innymi z wyżej wspomnianych powodów. Jednak ten sam UML to kobyła. Specyfikacja UML w wersji 2.5.1 zawiera 754 strony! Pracując jako programista od 2007 roku w całej swojej karierze nie spotkałem ani jednej osoby, która fanatycznie przestrzegałaby reguł opisujących UML’a1. Część funkcjonalność UML’a bardzo rzadko albo w ogóle nie jest wykorzystywana w praktyce.
Innymi słowy: tak, warto poznać UML’a, jednak wybiórczo.
Na początku muszę powiedzieć Ci trochę o moich doświadczeniach. Po kilku latach pracy zauważyłem, że nie czuję się dobrze w korporacjach. Projekty, które wykorzystują „ciężkie metodologie” do ich prowadzenia też raczej nie są dla mnie. Mimo pracy jako programista od 2007 roku doświadczyłem wyłącznie niedużej części dużego świata firm IT. Bardzo możliwe, że w środowisku, którego nie lubię nacisk na „czystego UML’a” jest większy.
Z mojego doświadczenia UML jest wykorzystywany w nieformalny sposób. To tak jak z językiem obcym – najważniejsza jest komunikacja. Możesz robić mnóstwo błędów, jeśli jednak potrafisz się dogadać z drugą stroną to jesteś w domu. Właśnie komunikacja i umiejętność przekazywania informacji jest tu kluczowa. Innymi słowy jeśli będziesz znać podstawy najbardziej istotnych diagramów, to ta wiedza powinna być wystarczająca.
Taki punkt widzenia potwierdza też badanie przeprowadzone na grupie programistów, testerów, architektów czy kierowników projektów2. Wynik przeprowadzonej ankiety potwierdza, że UML używany jest raczej nieformalnie.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
UML to diagram, rysunek. Do efektywnej pracy przyda Ci się zestaw narzędzi pozwalający tworzyć te diagramy.
Przede wszystkim polecam tablicę i marker (lub kartkę i długopis). To zdecydowanie najczęściej używane narzędzia przy pracy z nieformalnymi diagramami.
W trakcie pracy nad Samouczkiem, szczególnie pracując na artykułami dotyczącymi wzorców projektowych używam programów UMLet i yED. Są to darmowe programy, które pozwalają na tworzenie niektórych rodzajów diagramów UML. Istotne w nich dla mnie jest to, że same programy są proste a tworzone diagramy zapisane są w postaci tekstowej (można je eksportować do formatów graficznych). Format tekstowy świetnie nadaje się do zapisania w repozytorium git’a.
Istnieje całkiem sporo narzędzi, które mają dużo większe możliwości, jednak dla komercyjnych zastosowań są płatne.
Wspominałem to już wcześniej, jednak powtórzę to po raz kolejny. Poniżej prezentuję wyłącznie podzbiór diagramów. Skupiam się wyłącznie na tych, które doczekały się swojego praktycznego zastosowania w mojej dotychczasowej pracy komercyjnej. Pomijam diagramy, które wymagane były tylko w trakcie projektów na uczelni. Dodatkowo nie opisuję tu wszystkich możliwości, a jedynie te najczęściej używane.
Poniżej pokazałem diagram UML, który zawiera wszystkie dostępne diagramy. Na zielono oznaczyłem wyłącznie te, które opisuję w tym artykule:
Diagram klas (ang. class diagram) to chyba najczęściej używany diagram. Służy do pokazania klas i zależności między nimi. Pozwala na szczegółowy opis klas zwracając uwagę na dostępne atrybuty i operacje. Ta szczegółowość pozwala na generowanie kodu na podstawie kompletnego diagramu. W praktyce nigdy nie spotkałem się z takim zastosowaniem. Diagram klas pozwala na „narysowanie” wycinka większego systemu. Jest on jednym z najbardziej rozbudowanych diagramów w notacji UML.
Zacznę od pokazania symbolu klasy:
Klasa reprezentowana jest przez prostokąt podzielony na kilka części. W pierwszej z nich znajduje się nazwa klasy. W przykładzie jest to Customer
. Następna sekcja zawiera atrybuty, kolejna metody.
Elementy, które są podkreślone oznaczają elementy statyczne. Na przykład atrybut DEFAULT_PROMO_CODE
jest statycznym atrybutem klasy. Elementy pisane kursywą są abstrakcyjne (może dotyczyć także samej klasy), na przykład metoda fetchPromoCode
jest abstrakcyjna.
Zarówno atrybuty jak i operacje mogą być poprzedzone symbolem. Dopuszczalne są między innymi:
+
– element publiczny,#
– element „chroniony” (może odpowiadać protected
w języku Java),-
– element prywatny.Klasa w przykładzie ma cztery atrybuty. Trzy atrybuty instancji i jeden atrybut klasy (statyczny). Atrybuty zapisywane są w formacie nazwa:typ
. Ta sama klasa ma trzy metody. Prywatną metoda modifyOrderStats
i dwie metody publiczne. Zwróć uwagę na to, że metody mogą mieć określone typy parametrów i wartości zwracanej.
W podobny sposób oznacza się interfejs. W odróżnieniu od klasy zawiera on tak zwany stereotyp «interface»
. Na diagramie powyżej NotificationPipe
jest interfejsem zawierającym dwie metody. Zauważ, że w tym przypadku pominąłem symbole określające dostępność metod.
Atrybuty klas mogą być także opisane przez relacje pomiędzy klasami.
Pomiędzy klasami mogą występować relacje. Przykładem relacji jest dziedziczenie. Relacje reprezentowane są przez różne symbole. Proszę spójrz na rysunek poniżej, na którym zebrałem możliwe relacje:
Zacznę od lewej kolumny. Pierwsza przerywana strzałka reprezentuje implementację. Jest używana do tego żeby pokazać jaki interfejs jest implementowany przez klasę. Druga oznacza dziedziczenie. W tym przypadku grot wskazuje klasę nadrzędną.
W prawej kolumnie znajdują się strzałki pokazujące relacje pomiędzy klasami inne niż implementacja czy dziedziczenie. Posegregowałem je w rosnąco według tego jak silne są relacje przez nie opisywane.
Relacje ze strzałkami mogą być jednokierunkowe albo dwukierunkowe. W przypadku relacji jednokierunkowej strona bez grota używa strony, na którą pokazuje grot. W przypadku braku grota relacja jest dwukierunkowa. Trochę inaczej sprawa wygląda z rombami. Opiszę to jak poznasz rodzaje relacji.
Najsłabszą relacją pomiędzy klasami jest zależność. Reprezentowana jest przez przerywaną linię. Zależność oznacza, że jedna klasa w pewnym momencie używa innej, na przykład jako parametr, czy wartość zwracana metody. W przypadku zależności klasa, od której zależymy nie jest zapisana jako atrybut. Przykładem zależności w bibliotece standardowej Javy może być zależność Integer
od String
, widać ją na przykład w metodzie Integer.valueOf(String)
.
Kolejnym rodzajem relacji jest asocjacja. W tym przypadku jest to zapis, który może zastąpić atrybut klasy – jeśli nie chcesz dodawać atrybut w prostokącie reprezentującym klasę możesz użyć asocjacji. Przykładem asocjacji w bibliotece standardowej Javy może być FileInputStream
i String
. Klasa FileInputStream
posiada atrybut typu String
reprezentujący ścieżkę do pliku.
Kolejną relacją jest agregacja. Agregacja wprowadza w relacji stronę, która jest „właścicielem”. Jedna klasa agreguje inną. Relacja tego typu oznaczona jest przez ciągłą linię z pustym rombem po stronie właściciela. W bibliotece standardowej tego typu relacja występuje pomiędzy ArrayList
a klasą, której instancje przechowuje3.
Ostatnią relacją jest kompozycja. Kompozycja jest bardzo podobna do agregacji. Jest między nimi jedna znacząca różnica. W przypadku kompozycji „właściciel” jest odpowiedzialny za tworzenie (cykl życia) elementów, które grupuje. Przykładem kompozycji w bibliotece standardowej Javy może być implementacja HashMap
, która zarządza elementami w kolekcji opakowując je w instancje HashMap.Node
, które tworzy.
Proszę spójrz na diagram poniżej (dla czytelności pominąłem w nim atrybuty i operacje). Pokażę Ci na nim przykładowe relacje pomiędzy klasami:
LargeItem
implementuje interfejs Item
– implementacja,VIP
i OrdinaryCustomer
dziedziczą po klasie abstrakcyjnej Customer
– dziedziczenie,OrderCalculator
używa klasy Basket
– zależność,Basket
wie o kliencie z którym jest powiązana (klasie Customer
), odwrotne stwierdzenie także jest prawdziwe – asocjacja,Basket
może zawierać wiele instancji klasy Item
– agregacja,VIP
zawiera wiele instancji klasy BonusCode
i zarządza ich cyklem życia – kompozycja.Wiesz już, że strzałeczka oznacza kierunek relacji. Na przykład asocjacja pomiędzy ItemBundle
a Item
jest jednokierunkowa. ItemBundle
wie o powiązanej klasie Item
, Item
zaś nie wie nic o ItemBundle
. Jeśli strzałeczka nie jest umieszczona oznacza to, że relacja jest dwukierunkowa – można „przejść” z jednej klasy do drugiej w obu kierunkach4.
Trochę inaczej wygląda sprawa w relacjach agregacji i kompozycji. W tym przypadku romby oznaczają stronę, która agreguje drugą stronę relacji. Na powyższym przykładzie klasa VIP
zarządza cyklem życia BonusCode
. BonusCode
nic nie wie o klasie VIP
.
Nowością dla Ciebie jest także komentarz do relacji (contains), który może ją opisywać. Nowe są także oznaczenia pokazujące liczność. W powyższym przykładzie jeden koszyk może zawierać wiele elementów (0..*
).
Wiesz już jak oznaczana jest dwukierunkowa relacja zależności. Na przykładzie wyżej pokazałem ją pomiędzy klasami Customer
i Basket
. W praktyce występują też bardziej zagmatwane przypadki. Wyobraź sobie klasę reprezentująca książkę – Book
. Książka ma autora – Author
. Jedna książka może być napisana przez wielu autorów, a jeden autor może napisać wiele książek. To klasyczna relacja „wiele do wielu”.
Często tego typu relacje wprowadzają nową klasę, która reprezentuje samą relację. W tym przypadku byłoby to autorstwo – Authorship
. Poniższy diagram pokazuje przykładowe sposoby przedstawienia sytuacji tego typu na diagramie UML5.
W pierwszym przypadku Author
przechowuje kolekcję Book
i zarządza ich cyklem życia. Book
wie o liście swoich autorów.
W drugim przypadku Author
przechowuje kolekcję swoich „autorstw”. Podobną kolekcję przechowuje także Book
.
Wiesz już, że diagram klas pozwala zobaczyć powiązania pomiędzy klasami w wąskiej części systemu. Diagram komponentów (ang. component diagram) pozwala spojrzeć na projekt z większej odległości. W diagram komponentów kluczową rolę odgrywają komponenty. Proszę spójrz na przykładowy symbol komponentu:
Jak widzisz komponent to prostokąt ze specyficzną ikonką w prawym górnym rogu. Komponent na rysunku wymaga jeden interfejs i sam dostarcza dwa. Komponent UserManagement
wymaga dostępu do interfejsu persistence
a sam zapewnia dwa inne register
i ban
.
Interfejs to kreska z kółkiem (interfejs udostępniany przez komponent) lub kreska z półkolem (interfejs wymagany przez komponent). Relacje pomiędzy komponentami odbywają się poprzez interfejsy. Można powiedzieć, że komponenty łączy relacja zależności – najsłabsza z typów relacji występująca w diagramie klas.
Wiesz już jak wygląda symbol komponentu i interfejsów. Tylko czym ten komponent właściwie jest? Cytując za specyfikacją:
A Component represents a modular part of a system that encapsulates its contents and whose manifestation is replaceable within its environment.
Powyższe zdanie można przetłumaczyć jako: komponent reprezentuje wydzieloną, opakowaną część systemu, której reprezentacja jest wymienna w ramach swojego środowiska.
A teraz raz jeszcze, moimi słowami. Komponent to część systemu, która ma swoje interfejsy. Interfejsy czyli dokładnie określone sposoby komunikacji. Interfejsy służą do komunikacji z pozostałymi komponentami. Każdy z komponentów można zastąpić inną implementacją. Istotne jest to, że każda implementacja musi spełniać wymagania dotyczące jego interfejsów.
Jak widzisz definicja komponentów jest dość luźna. Do tego worka można wsadzić bardzo dużo rzeczy. Zaczynając od rozbudowanej implementacji w jednej klasie, poprzez ich zestaw znajdujący się w jednym pakiecie/module a na sporej części aplikacji kończąc. Ty jako autor diagramu sam decydujesz o tym do jakiego poziomu komponentów chcesz zejść. Istotne jest to, żeby poziom ten był spójny i prezentował wszystkie komponenty na diagramie „z podobnej odległości”.
Proszę spójrz na przykładowy diagram komponentów systemu, który może być odpowiedzialny za rezerwację biletów lotniczych:
Możesz na nim zobaczyć kilka komponentów, które są od siebie zależne. Każdy z nich definiuje interfejsy, które pozwalają komunikować się z innymi komponentami. Dla uproszczenia pominąłem opisowe nazwy interfejsów:
Przedstawiłem Ci już diagram klas i diagram komponentów. Wiesz już, że na system można spojrzeć z różnej odległości zwracając uwagę na coraz mniej szczegółów. Kolejnym stopniem ukrywającym szczegóły może być diagram wdrożenia (ang. deployment diagram).
Każdy działający projekt/aplikacja składa się z dwóch niezbędnych elementów. Oprogramowania (ang. software) i sprzętu (ang. hardware). Zauważ, że żaden z powyżej omówionych diagramów nie poruszał tematyki sprzętu. Tę lukę wypełnia diagram wdrożenia. Diagram wdrożenia służy do odwzorowania zależności pomiędzy oprogramowaniem i/lub sprzętem. To właśnie na diagramie wdrożenia można pokazać sposób w jaki aplikacja/projekt powinien być zainstalowany/wdrożony.
Także tutaj specyfikacja UML pozwala na dużą dowolność jeśli chodzi o szczegóły. Ty jako autor diagramu decydujesz, czy potrzebna jest dokładna specyfikacja poszczególnych elementów sprzętowych, czy zgrubna informacja w zupełności wystarczy.
Na początku swojej przygody z programowaniem ten diagram nie będzie Ci do niczego potrzebny. W późniejszym czasie bardzo pomoże Ci przy rozmowach na temat sposobu wdrożenia projektu.
Przykład poniżej pokazuje elementy, które możesz spotkać na diagramach wdrożenia:
Kolejno od lewej na rysunku możesz zobaczyć:
n2-highmem-64
,Zauważ, że podobnie jak w przypadku diagramu klas wstępują tu stereotypy, które dodają informacje. Mimo tego, że poszczególne części diagramu reprezentują zupełnie różne rzeczy, UML stosuje jedną graficzną reprezentację. W przypadku tego diagramu zupełnie nie przejmowałbym się sugestiami specyfikacji – w praktyce często spotyka się różnego rodzaju ikonki, które pozwalają lepiej zobrazować poszczególne elementy.
Proszę spójrz na przykład poniżej, który mógłby być diagramem wdrożenia dla aplikacji pozwalającej na rezerwację biletów:
Na diagramie wyżej możesz zobaczyć kilka oddzielnych klastrów (zestawów maszyn), przeznaczonych do wdrożenia poszczególnych komponentów. Kreski łączące komponenty obrazują powiązania między nimi.
Trzy poprzednie diagramy dotyczyły relacji pomiędzy elementami. Diagram sekwencji (ang. sequence diagram) jest jednym z tak zwanych diagramów interakcji. Kładzie on nacisk na komunikację, która odbywa się pomiędzy poszczególnymi klasami/obiektami. Diagram sekwencji pokazuje dokładnie sekwencję wykonania metod w poszczególnych obiektach. Diagram ten przydaje się do pokazania przebiegu skomplikowanej komunikacji.
Każdy z obiektów reprezentowany jest jako prostokąt połączony z pionową kreską. Ta linia oznacza „linię życia” – czas życia obiektu. Na diagramie może występować także tak zwany aktor. Aktor to człowiek albo system, który może brać udział w komunikacji. Proszę spójrz na przykład:
Wąskie pionowe prostokąty na liniach życia oznaczają czas, w którym dany aktor/obiekt był aktywny. Aktywność była niezbędna do wypełnienia żądania, które dany obiekt wysłał/otrzymał.
Niektóre obiekty mogą żyć krócej niż pozostałe. Koniec życia obiektu zaznaczany jest przez znak X
na ich linii życia.
Diagram, który pokazałem powyżej może służyć jako przykład opisujący mechanizm wysyłania wiadomości e-mail. Na początku aktor inicjalizuje proces, Instance 1
obsługuje akcję sendEmail
przekazując ją asynchronicznie do Instance 2
. Następnie dwukrotnie sprawdza czy wysłanie wiadomości się powiodło, po czym zwraca informację do aktora.
Wiesz już, że pionowe kreski oznaczają linię życia. Im wyżej na diagramie, tym wcześniej coś się wydarzyło. Poziome kreski oznaczają komunikaty. Jak widzisz istnieje kilka rodzajów komunikatów:
Strzałki w lewej kolumnie oznaczają komunikaty synchroniczne. Strzałka z ciągłą liną oznacza wysłanie komunikatu, strzałka z przerywaną linią otrzymanie odpowiedzi. W prawej kolumnie pokazałem strzałkę reprezentującą asynchroniczne wysłanie komunikatu.
Jak wspomniałem na początku artykułu nie było moim zamiarem wyczerpanie tematu. Celowo skupiłem się wyłącznie na diagramach, które moim zdaniem są najczęściej używane. Ponadto pominąłem sporą część możliwości, których nie używałem w praktyce. Właśnie te diagramy były dla mnie najbardziej przydatne w sesjach przy tablicach z kolegami z pracy. Jeśli jednak temat UML Cię zainteresował zapraszam Cię do zapoznania się z zestawem materiałów dodatkowych. Zacznę od materiałów oficjalnych:
Dodatkowo mam dla Ciebie artykuł podsumowujący badanie na temat użycia diagramów w praktyce.
Uczelnie techniczne często mają osobne kursy poświęcone tematyce UML’a. Czasami jest też tak, że UML zajmuje część wykładu dotyczącego na przykład inżynierii oprogramowania. Przygotowałem dla Ciebie zestaw odnośników do materiałów przygotowanych na uczelniach:
Na koniec zestawienie linków do artykułów na Wikipedii:
Znasz już mój punkt widzenia dotyczący UML’a. Wiesz, że moim zdaniem warto znać podstawy tego języka. Mogą Ci się one przydać w codziennej pracy. Jeśli lubisz pracować w bardziej formalnym środowisku może się okazać, że UML będzie niezastąpiony. Znasz kilka rodzajów diagramów, które mogą być przydatne. Znasz także darmowe narzędzia, które pozwalają na tworzenie diagramów UML.
Mam nadzieję, że artykuł przypadł Ci do gustu. Proszę daj znać w komentarzach co sądzisz o UML’u. Czy Twoim zdaniem znajomość tego języka przydaje się w codziennej pracy? A może to już tylko zaszłość, która powoli odchodzi do lamusa? Jestem ciekawy Twoje opinii.
Dodatkowo, jak zwykle, proszę Cię o podzielenie się odnośnikiem do artykułu ze swoimi znajomymi. W ten sposób pomożesz mi dotrzeć do nowych Czytelników, za co z góry Ci dziękuję. Jeśli nie chcesz pomiąć kolejnych artykułów proszę zapisz się do samouczkowego newslettera i polub Samouczka na Facebook’u. To tyle na dzisiaj, trzymaj się i do następnego razu!
Sam też nie mogę ich fanatycznie przestrzegać – nie znam tej specyfikacji wystarczająco dokładnie. ↩
Swoją drogą badanie było przeprowadzone na dość małej grupie kontrolnej. W związku z tym jest ryzyko, że wyniki nie są w pełni miarodajne. ↩
Tak na prawdę ArrayList
zawiera tablicę instancji typu Object
, to dzięki typom generycznym na zewnątrz widoczna jest inna klasa. ↩
Można powiedzieć, że to swego rodzaju uproszczenie. Tak naprawdę to można „przejść” z instancji jednej klasy do drugiej i odwrotnie. ↩
Do tego dochodzi jeszcze modelowanie relacji tego typu w relacyjnych bazach danych, jednak to jest już zupełnie inna para kaloszy i temat na osobny artykuł ;). ↩
To jest jeden z artykułów w ramach praktycznego kursu SQL. Proszę zapoznaj się z pozostałymi częściami, mogą one być pomocne w zrozumieniu materiału z tego artykułu.
Każde zapytanie z kursu możesz wykonać samodzielnie. Potrzebujesz do tego środowiska opisanego w pierwszym artykule kursu. Bardzo mocno Cię do tego zachęcam. Moim zdaniem najwięcej nauczysz się samodzielnie eksperymentując z zapytaniami.
Podzapytanie to zapytanie SQL, które umieszczone jest wewnątrz innego zapytania. Podzapytanie zawsze otoczone jest parą nawiasów ()
. Jak zwykle spróbuję pokazać to na przykładzie. Dla przypomnienia, najprostsze zapytanie SQL może wyglądać tak:
SELECT 1;
Po wykonaniu takiego zapytania otrzymasz pojedynczy wiersz zawierający jedną kolumnę z wartością 1
. Teraz trochę skomplikuję to zapytanie:
SELECT *
FROM (SELECT 1);
Efekt działania obu przykładów jest dokładnie taki sam. Drugi przykład używa podzapytania. Główne zapytanie SELECT * FROM
zwraca wszystkie wiersze zwrócone przez podzapytanie SELECT 1
. Przykład, który tu pokazałem jest trochę naciągany, bardziej prawdopodobny przykład może wyglądać następująco:
SELECT name
FROM artist
WHERE artistid IN (SELECT artistid
FROM album
GROUP BY artistid
HAVING COUNT(*) > 10);
Ponownie rozbiję to zapytanie na czynniki pierwsze. Proszę zwróć uwagę na podzapytanie:
SELECT artistid
FROM album
GROUP BY artistid
HAVING COUNT(*) > 10;
To zapytanie zwraca listę identyfikatorów płodnych artystów ;). Zapytanie zwraca identyfikatory artystów z tabeli album
, którzy opublikowali więcej niż dziesięć albumów.
W połączeniu z głównym zapytaniem otrzymuję nazwy artystów, którzy opublikowali więcej niż dziesięć albumów.
Poprzedni przykład pokazywał „zwykłe” podzapytania. Istnieją jeszcze tak zwane podzapytania skorelowane. Czasami nazywa się je także zapytaniami powiązanymi. Od zwykłych różnią się one tym, że są powiązane z nadrzędnym zapytaniem. Spróbuję wyjaśnić to na przykładzie:
SELECT trackid
,albumid
,name
FROM track AS outer_track
WHERE milliseconds > (SELECT 10 * MIN(milliseconds)
FROM track AS inner_track
WHERE inner_track.albumid = outer_track.albumid);
To zapytanie zwraca identyfikator utworu, identyfikator albumu i tytuł utworu z tabeli track
. Zwraca wyłącznie takie utwory, które są dziesięć razy dłuższe niż najkrótszy utwór z tego samego albumu. W tym przypadku podzapytanie używa dokładnie tej samej tabeli. Żeby móc odróżnić tabelę track
z zapytania wewnętrznego, od tej samej tabeli w zapytaniu zewnętrznym używam aliasów – słowa kluczowego AS
.
SELECT 10 * MIN(milliseconds)
FROM track AS inner_track
WHERE inner_track.albumid = outer_track.albumid;
Do tej pory w kursie posługiwałem się wyłącznie aliasami kolumn, jak widzisz istnieje także możliwość nadania aliasu tabelom.
Zapytania skorelowane nie są możliwe do wykonania bez dostępu do zapytania nadrzędnego. W tym przypadku zapytanie nie może być wykonane samodzielnie dlatego, że nie wie czym jest tabela outer_track
.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Powtórzę jeszcze raz przykład z poprzedniego punktu:
SELECT name
FROM artist
WHERE artistid IN (SELECT artistid
FROM album
GROUP BY artistid
HAVING COUNT(*) > 10);
Czy można osiągnąć ten sam efekt bez podzapytania1? Oczywiście, że można. Jednym ze sposobów jest użycie stałej listy identyfikatorów artystów. Listę tych identyfikatorów zwróci zapytanie:
SELECT artistid
FROM album
GROUP BY artistid
HAVING COUNT(*) > 10;
ArtistId
----------
22
58
90
Następnie taką listę można użyć w kolejnym zapytaniu:
SELECT name
FROM artist
WHERE artistid IN [22, 58, 90];
Takie podejście ma jednak swoje wady. Jedną z nich jest to, że trzeba wykonać dwa zapytania. Kolejną jest potrzeba modyfikowania drugiego zapytania na podstawie wyników pierwszego. Co więcej taka modyfikacja nie zawsze jest możliwa – co jeśli lista zwróconych identyfikatorów miałaby kilkadziesiąt tysięcy elementów?
Podzapytania mogą mieć wiele zastosowań. Czasami osiągnięcie oczekiwanego efektu nie jest możliwe bez użycia podzapytania. Stosowanie podzapytań czasami może także prowadzić do uproszczenia finalnego zapytania.
Podzapytania mogą mieć różny wpływ na wydajność zapytania. Jeśli wydajność zapytania jest kluczowa sprawdzaj plan zapytania upewniając się czy usunięcie podzapytań mogłoby przyspieszyć jego wykonanie2.
Podzapytanie może występować praktycznie wszędzie wewnątrz zapytania SQL. To gdzie podzapytanie może być użyte uzależnione jest od tego ile wartości zwraca. Jeśli podzapytanie zwraca pojedynczą wartość może być użyte jako część wyrażenia – na przykład w porównaniach, czy zwracanych kolumnach.
W przypadku gdy podzapytanie zwraca wiele wartości może być użyte na przykład w porównaniach czy jako tabela źródłowa. Poniższe przykłady powinny wyjaśnić poszczególne przypadki.
Wyobraź sobie raport, który musisz przygotować. Raport powinien zawierać wszystkie faktury klientów. Poszczególne kolumny powinny pokazywać identyfikator klienta, wartość faktury i globalną średnią wartość faktur. Tego typu problem możesz rozwiązać używając podzapytania:
SELECT customerid
,total
,(SELECT AVG(total)
FROM invoice) AS avg_total
FROM invoice
ORDER BY customerid
LIMIT 14;
W tym przypadku podzapytanie zwraca pojedynczą wartość – globalną średnią wartość wszystkich faktur:
SELECT AVG(total)
FROM invoice;
avg(total)
----------------
5.65194174757282
W połączeniu z zapytaniem głównym zwróci następujące wyniki:
CustomerId Total avg_total
---------- ---------- ----------------
1 3.98 5.65194174757282
1 3.96 5.65194174757282
1 5.94 5.65194174757282
1 0.99 5.65194174757282
1 1.98 5.65194174757282
1 13.86 5.65194174757282
1 8.91 5.65194174757282
2 1.98 5.65194174757282
2 13.86 5.65194174757282
2 8.91 5.65194174757282
2 1.98 5.65194174757282
2 3.96 5.65194174757282
2 5.94 5.65194174757282
2 0.99 5.65194174757282
Okazuje się, że raport nie jest idealny. Lepiej wyglądałoby zestawienie wartości poszczególnych faktur ze średnią faktur dla danego klienta. W tym przypadku podzapytanie musi bazować na kolumnie dostępnej w zapytaniu głównym. Aby móc tego dokonać niezbędne jest używanie aliasów (w tym przypadku aliasów dla tabel):
SELECT customerid
,total
,(SELECT AVG(total)
FROM invoice AS subquery_invoice
WHERE subquery_invoice.customerid = query_invoice.customerid) AS avg_total
FROM invoice AS query_invoice
ORDER BY customerid
LIMIT 14;
W tym przypadku podzapytanie nadal zwraca pojedynczą wartość. Jednak tym razem wartość ta zależna jest od identyfikatora klienta znajdującego się w danym wierszu. Dla przykładu wybrałem jeden z identyfikatorów:
SELECT AVG(total)
FROM invoice AS subquery_invoice
WHERE subquery_invoice.customerid = 1;
avg(total)
----------
5.66
Zwróć uwagę, że tym razem zapytanie główne zwraca średnią charakterystyczną dla każdego klienta (która jest rożna od średniej dla wszystkich klientów):
CustomerId Total avg_total
---------- ---------- ----------
1 3.98 5.66
1 3.96 5.66
1 5.94 5.66
1 0.99 5.66
1 1.98 5.66
1 13.86 5.66
1 8.91 5.66
2 1.98 5.37428571
2 13.86 5.37428571
2 8.91 5.37428571
2 1.98 5.37428571
2 3.96 5.37428571
2 5.94 5.37428571
2 0.99 5.37428571
Drugi przypadek pokazuje podzapytanie skorelowane. To podzapytanie powiązane jest z zapytaniem głównym. W odróżnieniu od pierwszego przypadku musi zostać wykonane wiele razy. Średnia użyta w pierwszym przypadku może być obliczona dokładnie raz dla uzyskania poprawnego wyniku.
FROM
Wyniki podzapytania użytego wewnątrz klauzuli FROM
traktowane są jakby były tabelą. Dlatego w tym przypadku podzapytanie może zwrócić wiele wartości. Kolumny użyte w podzapytaniu stają się kolumnami „tabeli” i mogą być użyte w zapytaniu głównym.
Proszę spójrz na przykład:
SELECT AVG(customer_total)
FROM (SELECT SUM(total) AS customer_total
FROM invoice
GROUP BY customerid);
Ponownie zacznę od analizy podzapytania:
SELECT SUM(total) AS customer_total
FROM invoice
GROUP BY customerid;
Podzapytanie sumuje wszystkie poszczególnych klientów. Zwraca dokładnie tyle wierszy ile jest wartości kolumny customerid
:
customer_total
--------------
39.62
37.62
39.62
39.62
40.62
…
Następnie taki wynik użyty jest do policzenia średniej z wszystkich sum. Ostatecznym wynikiem zapytania jest liczba pokazująca średnią sumę zamówień wszystkich klientów:
avg(customer_total)
-------------------
39.4677966101694
Podzapytania tego typu mogą być użyte w bardziej skomplikowanych zapytaniach. Proszę spójrz na przykład poniżej:
SELECT invoiceid
,total
,invoice.billingstate
,billingstate_avg.state_avg
FROM (SELECT billingstate
,AVG(total) AS state_avg
FROM invoice
GROUP BY billingstate) AS billingstate_avg JOIN invoice
ON billingstate_avg.billingstate = invoice.billingstate;
Analizę ponownie zacznę od podzapytania:
SELECT billingstate
,AVG(total) AS state_avg
FROM invoice
GROUP BY billingstate;
Podzapytanie używa klauzuli GROUP BY
żeby zwrócić średnią wartość zamówienia dla każdego stanu:
BillingState state_avg
------------ ---------------
5.6930693069307
AB 5.3742857142857
AZ 5.3742857142857
BC 5.5171428571428
CA 5.5171428571428
…
Następnie takie wyniki, używając klauzuli JOIN
, złączone są z tabelą invoice
. Kolumną używaną do złączenia jest billingstate
. Wynikiem jest zbiór wierszy zawierający faktury, które mają uzupełnioną kolumnę billingstate
(efekt złączenia). Każda taka faktura zestawiona jest później ze średnią obowiązującą w danym stanie:
InvoiceId Total BillingState state_avg
---------- ---------- ------------ ----------------
4 8.91 AB 5.37428571428571
5 13.86 MA 5.37428571428571
10 5.94 Dublin 6.51714285714286
13 0.99 CA 5.51714285714286
14 1.98 WA 5.66
…
WHERE
Podzapytanie może być także użyte do filtrowania wyników głównego zapytania. Przykład poniżej pokazuje takie zapytanie:
SELECT trackid
,name
,milliseconds
FROM track
WHERE milliseconds < (SELECT 10 * MIN(milliseconds)
FROM track);
W tym przypadku podzapytanie zwraca dziesięciokrotność długości najkrótszej ścieżki:
SELECT 10 * MIN(milliseconds)
FROM track;
10 * min(milliseconds)
----------------------
10710
Następnie ten wynik użyty jest do zwrócenia ścieżek, które są krótsze od tej wartości:
TrackId Name Milliseconds
---------- ---------- ------------
168 Now Sports 4884
170 A Statisti 6373
178 Oprah 6635
2461 É Uma Part 1071
3304 Commercial 7941
Możliwe jest także używanie podzapytań zwracających wiele wartości. Proszę spójrz na przykład poniżej:
SELECT trackid
,name
FROM track
WHERE mediatypeid IN (SELECT mediatypeid
FROM mediatype
WHERE name LIKE '%AAC%');
W tym przypadku podzapytanie zwraca listę identyfikatorów typów których nazwa pasuje do wyrażenia '%AAC%'
. Następnie te identyfikatory użyte są do odfiltrowania ścieżek, które mają odpowiednią wartość kolumny mediatypeid
. Innymi słowy zapytanie zwraca ścieżki, które są w formacie pasującym do '%AAC%'
.
Wyżej wspomniałem już o zapytaniach powiązanych. Musisz wiedzieć, że podzapytania powiązane mogą wystąpić także w innych miejscach. Poniżej pokazuję Ci przykład takiego podzapytania występującego w klauzuli WHERE
:
SELECT albumid
,name
,milliseconds
FROM track AS outer_track
WHERE milliseconds < (SELECT AVG(milliseconds)
FROM track AS inner_track
WHERE inner_track.albumid = outer_track.albumid);
W tym przypadku podzapytanie zwraca średnią długość ścieżki dla każdego albumu. Następnie wartość ta użyta jest w głównym zapytaniu. Pozwala ona zwrócić wyłącznie te wiersze, które dotyczą ścieżek o długości krótszej niż średnia z ich albumu.
EXISTS
W artykule dotyczącym klauzuli WHERE
pominąłem między innymi możliwość użycia operatora EXISTS
. Operator EXISTS
powoduje, że zwrócone są wyłącznie te wiersze, dla których podzapytanie zwróci co najmniej jeden wiersz. Proszę spójrz na przykład:
SELECT *
FROM employee AS outer_employee
WHERE EXISTS (SELECT *
FROM employee AS inner_empolyee
WHERE inner_employee.reportsto = outer_employee.employeeid);
W tym przypadku skorelowane podzapytanie zwraca wiersze, które połączone są relacją szef-podwładny. Wiersze, które zawierają pracowników nie posiadających podwładnych są pominięte. Dzieje się tak dlatego, że podzapytanie w ich przypadku nie zwróci ani jednego wiersza.
ALL
i ANY
Operatory ALL
i ANY
nie są obsługiwane przez bazę SQLite.
Operatory ALL
i ANY
używa się w połączeniu z operatorami porównania z klauzuli WHERE
.
Na przykład wyrażenie kolumna > ALL (podzapytanie)
oznacza, że kolumna musi mieć większą wartość niż wszystkie wartości zwrócone przez podzapytanie.
Analogicznie kolumna <= ANY (podzapytanie)
oznacza, że kolumna musi mieć wartość mniejszą bądź równą którejkolwiek z wartości zwróconych przez podzapytanie.
Chociaż SQLite nie wspiera tych operatorów identyczne zachowanie, w przypadku niektórych zapytań, można uzyskać stosując funkcje MIN
albo MAX
. Dla przykładu dwa poniższe zapytania dałyby te same wyniki:
SELECT *
FROM track
WHERE milliseconds < ANY (SELECT milliseconds
FROM track);
SELECT *
FROM track
WHERE milliseconds < (SELECT MAX(milliseconds)
FROM track);
Podzapytania zwracające pojedynczą wartość mogą traktowane być jako wyrażenie. W związku z tym mogą wystąpić w innych miejscach zapytania SQL. Kilka zapytań tego typu omówiłem dokładnie w poprzednich podpunktach.
Poniżej pokazuję kilka przykładów obrazujących użycie podzapytań w innych miejscach zapytania SQL.
ORDER BY
Dziwne, ale poprawne sortowanie:
SELECT *
FROM artist
ORDER BY (SELECT MAX(albumid)
FROM album
WHERE artist.artistid = album.artistid);
LIMIT
Ponownie dziwne, ale poprawne ograniczanie liczby wierszy:
SELECT * FROM album LIMIT (SELECT COUNT(*)
FROM artist);
HAVING
Tym razem podzapytanie zostało użyte do zwrócenia wierszy, dla których suma jest większa niż suma w jednym ze stanów:
SELECT customerid
,SUM(total) AS sum_total
FROM invoice
GROUP BY customerid
HAVING sum_total > (SELECT SUM(total)
FROM invoice
WHERE billingstate = 'WA');
JOIN
Często istnieje wiele sposobów na uzyskanie tych samych wyników. W przypadku niektórych podzapytań możliwe jest ich zastąpienie odpowiednimi złączeniami. Poprawne użycie klauzuli JOIN
może pomóc w usunięciu niechcianego podzapytania.
Do tej pory w ramach kursu SQL omawiałem wyłącznie zapytania typu SELECT
. W języku SQL istnieją także inne rodzaje zapytań. Musisz wiedzieć, że także w zapytaniach typu UPDATE
czy DELETE
możesz spodziewać się użycia podzapytań.
To, że coś jest możliwe, wcale nie znaczy, że powinno być używane. Zapytania SQL szybko mogą stać się mało czytelne. Przez co będą trudne w zrozumieniu i późniejszym utrzymaniu. Jeśli podzapytanie wprowadza niepotrzebne zamieszanie postaraj się rozwiązać problem inaczej – czasami jest to możliwe na przykład przy użyciu klauzuli JOIN
.
Ta sama klauzula może także pomóc w optymalizowaniu zapytania zawierającego podzapytania. Dobrą praktyką jest porównanie planu wykonania obu wersji zapytania. Plan zapytania możesz sprawdzić używając EXPLAIN <zapytanie sql>
.
Podzapytania to twory, które mogą być zagnieżdżone. W zależności od silnika bazy danych limit zagnieżdżonych podzapytań może być różny. Mimo tego, że takie konstrukcje są możliwe, w codziennej pracy nie spotkałem się za podzapytaniami zagnieżdżonymi więcej niż dwa poziomy.
Nadmierne zagnieżdżanie podzapytań nie jest dobrą praktyką. Takie łańcuszki nie poprawiają czytelności zapytania. Dodatkowo powoduje problemy z jego utrzymaniem. Jeśli musisz stosować więcej niż jeden, dwa poziomy zagnieżdżenia zastanów się czy nie można rozwiązać tego problemu inaczej.
Poniżej przygotowałem dla Ciebie zestaw kilku zadań, które pozwolą Ci sprawdzić wiedzę dotyczącą podzapytań w praktyce. Zanim zerkniesz do przykładowego rozwiązania zachęcam się do samodzielnej próby rozwiązania zadań – w ten sposób nauczysz się najwięcej.
Napisz zapytanie używając podzapytań, które zwróci:
total
) faktur (tabela invoice
), których kwota jest powyżej średniej wartości wszystkich faktur,album
) dla artystów, którzy opublikowali więcej niż dwa albumy,customerid
) i wartość faktur ponad średnią wartość faktur danego klienta (wartość - średnia
). Zapytanie powinno zwrócić wyłącznie wiersze gdzie ta różnica jest większa od 0
,JOIN
:
SELECT name
FROM artist JOIN album
ON artist.artistid = album.artistid
GROUP BY name
HAVING COUNT(*) > 10;
JOIN
:
SELECT invoiceid
,total
,invoice.billingstate
,billingstate_avg.state_avg
FROM (SELECT billingstate
,AVG(total) AS state_avg
FROM invoice
GROUP BY billingstate) AS billingstate_avg JOIN invoice
ON billingstate_avg.billingstate = invoice.billingstate;
1.
SELECT SUM(total)
FROM invoice
WHERE total > (SELECT AVG(total)
FROM invoice);
2.
SELECT AVG(how_many)
FROM (SELECT COUNT(*) AS how_many
FROM album
GROUP BY artistid
HAVING how_many > 2);
3.
SELECT customerid
,(total - (SELECT AVG(total)
FROM invoice AS i2
WHERE i1.customerid = i2.customerid)) AS above_average
FROM invoice AS i1
WHERE above_average > 0;
4.
SELECT name
FROM artist
WHERE artistid IN (SELECT artistid
FROM album
GROUP BY artistid
HAVING COUNT(*) > 10);
5.
SELECT invoiceid
,total
,billingstate
,(SELECT AVG(total) AS state_avg
FROM invoice
WHERE billingstate = outer.billingstate)
FROM invoice AS outer
WHERE billingstate IS NOT NULL;
Po lekturze artykułu wiesz już czym są podzapytania. Wiesz doskonale gdzie można ich używać. Udało Ci się także poznać kilka dobrych praktyk dotyczących używania podzapytań. Po samodzielnym rozwiązaniu zadań możesz śmiało powiedzieć, że potrafisz posługiwać się podzapytaniami.
Artykuł ten zamyka część kursu poświęconą zapytaniom typu SELECT
. W kolejnych częściach kursu poznasz pozostałe elementy języka SQL niezbędne do codziennej pracy.
Mam nadzieję, że artykuł przypadł Ci do gustu. Udało Ci się rozwiązać zadania? Podziel się swoimi rozwiązaniami! Spojrzenie na ten sam problem z innego punktu widzenia pozwoli wszystkim na nauczenie się jeszcze więcej.
Zależy mi na dotarciu do nowych Czytelników, jeśli uważasz, że ten artykuł byłby wartościowy dla kogoś z Twoich znajomych bardzo proszę podziel się z nim odnośnikiem do tego artykułu. Z góry dziękuję!
Jeśli nie chcesz ominąć kolejnych artykułów w przyszłości proszę dopisz się do samouczkowego newslettera i polub Samouczka na Facebook’u. Trzymaj się i do następnego razu!
]]>Artykuł ten opisuje przykładową implementację zbioru. Zbiór jest abstrakcyjnym typem danych, który występuje w wielu językach programowania. Zasada pracy ze zbiorami są niezależnie od języka programowania.
Przykładową implementację przygotowałem w Javie. Żeby wynieść jak najwięcej z tego artykułu potrzebna jest wiedza na temat hashCode
i equals
. Niezbędna jest też znajomość kontraktu pomiędzy metodami equals
i hashCode
.
Do zrozumienia przykładowej implementacji niezbędna będzie też wiedza o typach generycznych.
Może przydać się też wiedza na temat szacowania złożoności obliczeniowej.
W poprzednich artykułach z serii opisujących listę wiązaną czy tablicę asocjacyjną pominąłem kwestie definicji. Używałem określenia struktura danych i abstrakcyjny typ danych zamiennie. Tym razem chciałbym zwrócić Twoją uwagę na drobną różnicę pomiędzy tymi określeniami.
Abstrakcyjny typ danych definiuje zachowanie danego typu. Określa zestaw operacji, które można na tym typie wykonać. Opis abstrakcyjnego typu danych zawiera także cechy charakterystyczne dla danego typu.
Na przykład zbiór jest abstrakcyjnym typem danych (niżej opiszę jego własności), a TreeSet
czy HashSet
są implementacjami tego abstrakcyjnego typu danych. Te implementacje używają rożnych struktur danych. Innym przykładem może być abstrakcyjny typ danych tablica asocjacyjna, której implementacja może używać tablicy i listy wiązanej.
Zbiór jest abstrakcyjnym typem danych, który ma następujące własności:
Podstawowymi operacjami, które można przeprowadzić na zbiorze jest dodanie elementu, usunięcie elementu i sprawdzenie czy dany element jest częścią zbioru.
Zbiór jest także jednym z podstawowych pojęć matematycznych.
Tematem tego artykułu nie jest zbiór w kontekście matematycznym. Chciałbym jednak zwrócić Twoją uwagę na podstawowe operacje, które można przeprowadzać na zbiorach. Ta podstawowa wiedza może także przydać się w kontekście programowania.
Poza operacjami przyda się też wiedza o tak zwanym zbiorze pustym. Zbiór pusty jak sama nazwa wskazuje jest pusty, nie ma żadnego elementu.
Nazywany także przecięciem dwóch zbiorów. Przecięcie to nic innego jak część wspólna dwóch zbiorów. Przecięcie dwóch zbiorów może prowadzić do uzyskania:
Iloczyn dowolnego zbioru ze zbiorem pustym zawsze jest zbiorem pustym.
Suma dwóch zbiorów to zbiór, który zawiera wszystkie elementy z obu sumowanych zbiorów.
Różnica zbioru A i zbioru B to zbiór zawierający wszystkie elementy, które są w zbiorze A i nie ma ich w zbiorze B.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
W ramach tego artykułu skupię się na przykładowej implementacji, która oparta jest o funkcję skrótu (w języku Java jest to hashCode
). Przedstawiona tu implementacja będzie uproszczoną wersją klasy HashSet
, znajdującej się w bibliotece standardowej.
hashCode
i equals
Podobnie jak w przypadku tablicy asocjacyjnej opartej o funkcję skrótu tak i tutaj hashCode
i equals
pełnią kluczową rolę.
Także tutaj na podstawie wartości funkcji hashCode
obliczone zostanie „wiaderko”, do którego wpadnie dany element. Następnie elementy wewnątrz tego samego wiaderka porównywane będą przy pomocy metody equals
. Takie podejście pozwala na uzyskanie bardzo dobrej złożoności obliczeniowej.
Podobnie jak w przypadku tablicy asocjacyjnej kluczowe jest zachowanie kontraktu pomiędzy tymi metodami.
Jak wspomniałem wyżej zbiór oferuje kilka podstawowych operacji. Na potrzeby tego artykułu ograniczę je do takiego interfejsu:
public interface SimpleSet<E> {
int size();
boolean add(E element);
boolean remove(E element);
boolean contains(E element);
}
int size()
– metoda zwraca liczbę elementów zbioru,boolean add(E element)
– metoda dodaje element to zbioru, zwraca true
jeśli element został dodany,boolean remove(E element)
– metoda usuwa element ze zbioru, zwraca true
jeśli element został usunięty,boolean contains(E element)
– metoda zwraca flagę informującą czy element istnieje w zbiorze.HashSet
i HashMap
Zacznę od krótkiego przypomnienia czym jest tablica asocjacyjna. Ta struktura pozwala na przechowywanie kluczy i odpowiadających im wartości. Implementacja HashMap
zakłada, że tablica asocjacyjna zawiera unikalny zestaw kluczy. Innymi słowy nie może w niej być dwóch takich samych kluczy.
Tablica asocjacyjna, podobnie jak zbiór, nie zwraca uwagi na porządek kluczy2. Zbiór nie zawiera duplikatów, mapa nie przechowuje zduplikowanych kluczy.
Czy widzisz tu pewne podobieństwo pomiędzy zbiorem a tak zdefiniowaną tablicą asocjacyjną? Powiem więcej, bardzo często implementacje zbioru pod spodem używają tablicy asocjacyjnej.
Są też języki programowania, w których w bibliotece standardowej nie ma zbiorów a jedynie tablice asocjacyjne. Jednym z takich języków jest Go.
Jak wspomniałem wcześniej zbiór jest bardzo podobny do tablicy asocjacyjnej. To podobieństwo jest widoczne także w przykładowej implementacji:
public class SimpleHashSet<T> implements SimpleSet<T> {
private static final Object PRESENT = new Object();
private final SimpleMap<T, Object> map = new SimpleHashMap<>();
@Override
public int size() {
return map.size();
}
@Override
public boolean add(T item) {
return map.put(item, PRESENT) == null;
}
@Override
public boolean remove(T item) {
return map.remove(item) == PRESENT;
}
@Override
public boolean contains(T item) {
return map.containsKey(item);
}
}
Zauważ, że cały mechanizm związany z funkcją skrótu, kubełkami, dynamicznym rozszerzaniem pojemności zbioru jest ukryty w implementacji tablicy asocjacyjnej. Sam zbiór korzysta jedynie z publicznych metod. Jeśli nie znasz któregokolwiek z tych mechanizmów koniecznie przeczytaj artykuł o tablicy asocjacyjnej.
Interesującym zabiegiem jest tu użycie instancji PRESENT
. Dzięki takiemu podejściu minimalizowana jest wielkość zbioru, istnieje tylko jeden obiekt wartości współdzielony pomiędzy wszystkimi kluczami.
Implementacja zbioru opartego o funkcje skrótu jest na tyle prosta, że zestaw testów jednostkowych ma dużo więcej linijek kodu ;).
Złożoność obliczeniowa poszczególnych operacji odpowiada złożoności obliczeniowej tablicy asocjacyjnej. Wynika to z faktu, że każda operacja wywołuje odpowiednią metodę zaimplementowaną w tablicy asocjacyjnej.
Ma to dokładnie takie same konsekwencje jak w przypadku mapy opartej o funkcję skrótu. Jeśli funkcja skrótu jest „dobra” wówczas złożoność operacji wynosi Ο(1)
. Jeśli jest zła, złożoność obliczeniowa spada do Ο(n)
.
Dla przypomnienia możesz rzucić okiem na złożoność obliczeniową mapy.
Jak wspomniałem na początku artykułu zbiór tak na prawdę nie jest strukturą danych. Zbiór to abstrakcyjny typ danych, który może mieć wiele implementacji. Jedną z nich przedstawiłem w tym artykule. Sam zbiór nie może być serializowalny/wielowątkowo bezpieczny/posortowany, ale jego konkretna implementacja już tak. Na przykład implementacja zbioru oparta o drzewo jest posortowana, a ta oparta o funkcję skrótu już nie musi taka być.
Zbiór z definicji jest nieuporządkowanym zbiorem elementów, które nie mogą się powtarzać. Lista to elementy, które mogą się powtarzać. Dodatkowo lista ma swój określony porządek.
Tablica asocjacyjna zawiera unikalny zbiór kluczy, Każdy z kluczy ma przyporządkowaną wartość. Zbiór kluczy w mapie nie zawiera duplikatów. Można powiedzieć, że zbiór jest częścią mapy – zbiór nie zawiera mapowania. To podobieństwo widać w przykładowej implementacji.
W artykule tylko musnąłem zagadnienia związane z matematyką. Jeśli chcesz możesz dowiedzieć się czegoś więcej o algebrze zbiorów.
Polecam lekturę dokumentacji klasy HashSet
i przejrzenie implementacji HashSet
w OpenJDK. Możesz też rzucić okiem na implementację zbioru opartą o drzewa.
Jak zwykle zachęcam Cię też do przejrzenia kodu źródłowego użytego w artykule.
Teraz wiesz czym jest zbiór. Znasz złożoność obliczeniową poszczególnych operacji. Znasz podstawowe operacje, które można przeprowadzać na zbiorach. Masz też pod ręką zestaw dodatkowych materiałów, które pozwolą Ci poszerzyć zdobytą wiedzę. Możesz śmiało powiedzieć, że udało Ci się poznać kolejny abstrakcyjny typ danych :).
Jeśli znasz kogoś komu materiał zebrany w tym artykule może się przydać będę wdzięczny za podzielenie się linkiem. Zależy mi na dotarciu do nowych Czytelników, a Ty możesz mi w ten sposób pomóc – z góry dziękuję!
Jeśli nie chcesz pominąć kolejnych artykułów na blogu dopisz się do samouczkowego newslettera. Możesz też polubić profil Samouczka na Facebook’u. To tyle na dzisiaj, trzymaj się i do następnego razu!
]]>Czytasz artykuły na różnych stronach internetowych. Jedną z tych stron jest Samouczek Programisty ;). Są strony na które zaglądasz regularnie. Raz na jakiś czas sprawdzasz czy na stronach, które Cię interesują nie pojawiły się nowe artykuły. Po lekturze nowych artykułów spisujesz swoje notatki. Jeśli stron do śledzenie masz sporo pojawia się problem. Regularne sprawdzanie czy pojawiły się nowe treści jest mało efektywne. Możesz rozwiązać ten problem na kilka sposobów, jednym z nich może być zapisanie się do newslettera. Można powiedzieć, że zapisanie się na newsletter czyni z Ciebie obserwatora strony.
Ten sam problem występuje w projektach informatycznych. Istnieją zdarzenia, które powinny wyzwalać pewne zachowanie. Wystąpienie zdarzenia powoduje to, że obserwator aktualizuje swój stan na podstawie zmiany obserwowanego elementu. Aktywne sprawdzanie czy zdarzenie wystąpiło w większości przypadków nie jest dobrym rozwiązaniem. W projektach informatycznych problem tego typu rozwiązany jest przez wzorzec projektowy obserwator (ang. observer).
Ten wzorzec projektowy opiera się o dwa interfejsy. Jeden z nich reprezentuje obserwatora. Drugi element, który jest obserwowany:
Interfejs Observable
zawiera trzy metody:
attach(Observer)
– powoduje dodanie nowego obserwatora (obserwator jest zainteresowany zmianami),detach(Observer)
– powoduje usunięcie istniejącego obserwatora (obserwator nie jest już zainteresowany zmianami),notify()
– powoduje powiadomienie wszystkich obserwatorów o wystąpieniu zmiany.Interfejs Observer
zawiera wyłącznie jedną metodę:
update()
– metoda jest wywołana przez Observable
w momencie wystąpienia zmiany.Interfejsy nie przechowują żadnego stanu, który może się zmienić. Właściwe obiekty implementują te interfejsy i to one przechowują stan.
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Interfejsy przedstawione na diagramie UML mogą wyglądać następująco:
public interface Observable {
void attach(Observer observer);
void detach(Observer observer);
void notifyObservers();
}
public interface Observer {
void update();
}
Posłużę się przykładem, który przytoczyłem na początku artykułu. Wyobraź sobie blog, na którym publikowane są artykuły. Blog pozwala się obserwować – implementuje interfejs Observable
. W momencie dodania nowego czytelnika zostaje on dodany do zbioru obserwatorów.
Następnie w momencie publikacji nowego artykułu (metoda publishArticle
) zmieniany jest wewnętrzny stan instancji klasy Blog
. Po tej zmianie wywołana jest metoda notifyObservers
. Wewnątrz tej metody na każdej z instancji implementującej Observer
wywołana jest metoda update
:
public class Blog implements Observable {
private Set<Observer> observers = new HashSet<>();
private String newestArticle;
@Override
public void attach(Observer observer) {
observers.add(observer);
}
@Override
public void detach(Observer observer) {
observers.remove(observer);
}
@Override
public void notifyObservers() {
observers.forEach(Observer::update);
}
public String getNewestArticle() {
return newestArticle;
}
public void publishArticle(String article) {
newestArticle = article;
notifyObservers();
}
}
Obserwatorem jest czytelnik reprezentowany przez klasę Reader
. Czytelnik wie jaki zasób obserwuje, przechowuje go w atrybucie blog
. W momencie powiadomienia, czyli w trakcie wywołania metody update
, sprawdzany jest stan atrybutu blog
i Reader
może odpowiednio na tę zmianę zareagować. W tym przypadku informuje o najnowszym artykule:
public class Reader implements Observer {
private final Blog blog;
private String newestArticle;
public Reader(Blog blog) {
this.blog = blog;
newestArticle = blog.getNewestArticle();
}
@Override
public void update() {
newestArticle = blog.getNewestArticle();
System.out.println(String.format("An article „%s” was published!", newestArticle));
}
}
Przekładając klasy z tego przykładu na te użyte w diagramie UML:
SomeObservable
– Blog
,SomeObserver
– Reader
.Obserwator to wzorzec, który jest bardzo generyczny. W swojej podstawowej wersji nie posiada mechanizmu na informowanie o tym co dokładnie zmieniło się w obserwowanym obiekcie. Takie podejście ma swoje wady i zalety.
Jedną z zalet stosowania tego wzorca projektowego jest to, że klasa implementująca interfejs Observer
nie musi aktywnie sprawdzać czy interesujący ją obiekt się zmienił.
Dzięki zastosowaniu tego wzorca projektowego można w czysty sposób odizolować od siebie obiekty. Nie są one ze sobą sztywno powiązane. Dodatkowo szeroka definicja metody update
pozwala na informowanie o zdarzeniach różnego rodzaju.
Niewątpliwą zaletą także jest to, że obiekt obserwowany może poinformować wielu obserwatorów używając tego samego protokołu.
Obserwator powiadomiony o zmianie sam musi dojść do tego co się zmieniło w obiekcie obserwowanym. Czasami takie sprawdzenie może nie być trywialne. Co więcej nie jest to potrzebne, bo obserwowany obiekt doskonale wie co się zmieniło – sam przecież o tej zmianie informuje swoich obserwatorów.
Można to obejść poprzez rozszerzenie metody attach
lub update
. Na przykład zmiana deklaracji z attach(Observer observer)
na attach(Observer observer, EnumType event)
może informować obiekt informowany o tym, że dany obserwator zainteresowany jest jedynie podzbiorem zdarzeń.
Podobną zmianę można wprowadzić w metodzie update
zmieniając ją z update()
na update(EventDetails eventDetails)
. Zmiany tego typu sprawiają, że interfejsy Observable
czy Observer
nie są już tak generyczne.
Przy synchronicznym powiadamianiu obserwatorów może wystąpić sytuacja, w której wywołania metody update
zajmują lwią część czasu zmiany stanu obiektu obserwowanego.
W standardowej bibliotece języka Java możesz spotkać całą masę różnych implementacji interfejsu EventListener
. Jest to interfejs bazowy dla pozostałych interfejsów, które służą do informowania o wystąpieniu pewnego zdarzenia. To nic innego jak Observer
, z rozszerzoną metodą update
.
Jeśli udało Ci się już przeczytać artykuł o wątkach to wiesz o mechanizmie powiadamiania. Także tam można dopatrzeć się analogii do wzorca projektowego obserwator. Wątek, oczekujący na pewien zasób jest powiadamiany kiedy zasób staje się dostępny.
Można powiedzieć, że MVC (ang. Model View Controller) jest wzorcem architektonicznym. Połączenia pomiędzy poszczególnymi komponentami można uzyskać stosując wzorzec obserwatora. Na przykład widok obserwuje zmiany w modelu, model informuje widok o zmianach, które powinny zostać pokazane użytkownikowi.
Ćwiczenie polega na zaimplementowaniu klasy zdarzenia ArticleEvent
, która będzie zawierała informacje o nowym artykule opublikowanym na blogu. Wymaga to także zmiany metody update
. Niech obserwator użyje informacji przekazywanej w tym zdarzeniu do pokazania najnowszego artykułu. Czy w takim przypadku Reader
potrzebuje instancji klasy Blog
?
Niezmiennie, we wszystkich artykułach z serii poświęconej wzorcom projektowym polecam książkę Design Patterns – Gamma, Helm, Johnson, Vlissides. Jeśli miałbym polecić wyłącznie jedno źródło to poprzestałbym na tej książce.
Możesz też przeczytać więcej o obserwatorze z innego punktu widzenia. Wartościowym źródłem są także artykuły na polskiej i angielskiej Wikipedii.
Zachęcam Cię też do zajrzenia do kodu źródłowego, który użyłem w tym artykule.
Po lekturze tego artykułu wiesz czym jest obserwator. Artykuł pokazał Ci też pewne wariacje tego wzorca projektowego. Po wykonaniu ćwiczenia potrafisz zaimplementować swój własny obserwator. Można powiedzieć, że udało Ci się poznać kolejny wzorzec projektowy. Gratulacje!
Czy udało Ci się użyć tego wzorca w praktyce? W czym pomógł w Twoim projekcie? Podziel się Twoją opinią z innymi Czytelnikami :).
Jeśli znasz kogoś komu obserwator może się przydać proszę podziel się odnośnikiem do tego artykułu. Kto wie, może dzięki Tobie Samouczek zyska kolejnego Czytelnika? Z góry dziękuję!
Jeśli nie chcesz pominąć kolejnych artykułów proszę dopisz się do samouczkowego newslettera i polub profil Samouczka na Facebook’u. To tyle na dzisiaj, trzymaj się i do następnego razu!
]]>Tego typu praktyka spotykana jest także w codziennej pracy programisty. Przeglądy kodu (ang. code review) to bardzo dobry sposób na poznawanie projektu i naukę. Najlepsze w tym wszystkim jest to, że uczy się zarówno osoba, która sprawdza kod jak i ta której kod jest sprawdzany.
Na przestrzeni kilku lat prowadzenia Samouczka widziałem już różne przypadki. W tym artykule zbieram najczęściej popełniane błędy wraz z propozycją ich rozwiązania.
Część proponowanych tu rozwiązań jest subiektywna. Nie jest poparta żadną specyfikacją czy dokładnym opisem „u źródła”. Masz prawo nie zgadzać się z moją opinią, z chęcią usłyszę Twój punkt widzenia w komentarzach.
Zanim zacznę opisywać jakiekolwiek standardy muszę zaznaczyć jedną bardzo ważną rzecz. Jeśli w projekcie, z którym pracujesz istnieje już jakaś konwencja proponuję nadal ją stosować. Jeśli wejdziesz między wrony, musisz krakać jak i one.
Jeśli Twoim zdaniem ta konwencja jest bez sensu porozmawiaj o tym z innymi członkami zespołu. Każdy przypadek powinien być rozpatrywany indywidualnie, a konsensus może usprawiedliwić zmianę istniejącej konwencji.
W języku Java „obowiązuje” konwencja nazewnicza. Kompilator nie będzie marudził jeśli kod, który napiszesz nie będzie jej przestrzegał. Będzie marudziła kolejna osoba, która z tym kodem będzie pracowała. W praktyce często jest tak, że raz napisany kod czytany jest wielokrotnie. Często przez kogoś innego niż autor. Stosowanie konwencji nazewniczej pozwala na łatwiejsze zorientowanie się w kodzie, z którym się pracuje.
Mimo tego, że pisownia jest ważna to nie jest najważniejsza. Najbardziej istotne jest nadanie poszczególnym elementom dobrej nazwy. Pracuję w IT od 2007 roku, nadal nie potrafię tego robić dobrze. W branży IT panuje obiegowa opinia:
There are only two hard things in Computer Science: cache invalidation and naming things.
Istotne jest aby nazwy elementów (typów, parametrów, atrybutów, metod itd.) oddawały to co dany element zawiera/robi. Złe nazwy mogą wprowadzić w błąd, co może utrudnić zrozumienie kodu.
Klasy, typy wyliczeniowe, interfejsy powinny być nazwane zgodnie z PascalCase. Oznacza to tyle, że nazwy powinny być jednym ciągiem znaków, w którym każde kolejne słowo zaczyna się od wielkiej litery. Dobrze, jeśli te nazwy są rzeczownikami. Problem jest z akronimami, nawet JDK nie zachowuje tu konwencji – część akronimów pisana jest wielkimi literami (na przykład URL
), część używając PascalCase (na przykład Http
). W tym przypadku proponuję Ci używanie pierwszego podejścia.
Moim zdaniem przykłady poniżej pokazują nazwy, które można poprawić:
// incorrect
class anonymousUser {
}
interface Bus_driver {
}
enum color {
}
Poprawnymi przykładami nazw mogą być:
// correct
class User {
}
interface PageCollector {
}
enum URLSchema {
}
Metody w języku Java zwykło się nazywać używając camelCase. Oznacza to tyle, że pierwsze słowo pisane jest małą literą. Każdy kolejny wyraz zaczyna się wielką literą. Przykładami poprawnych nazw mogą być:
// correct
class CodeExecutor {
String snippet;
int returnCode;
Future<Integer> executeAsynchronously() {
// ...
}
}
Swego rodzaju wyjątkiem od reguły są stałe – atrybuty przypisane do klasy oznaczone słowem kluczowym final
. Te powinny być pisane wyłącznie wielkimi literami używając SCREAMING_SNAKE_CASE. Poszczególne słowa pisane wielkimi literami powinny być oddzielone symbolem _
. Na przykład:
// correct
class Temperature {
public static final double BOILING_WATER_CELSIUS = 100;
}
Mimo tego, że Java pozwala na używanie domyślnego pakietu (brak deklaracji package
) nie jest to zalecane. Przyjęło się, że nazwa pakietu składa się z małych liter oddzielonych kropkami. Każdy z członów opisuje bardziej szczegółowo swoją zwartość.
Przyjęło się, że pakiety mają postać „odwróconej domeny”:
// incorrect
package pckg.pl;
// correct
package pl.samouczekprogramisty.kursjava.loops;
Pobierz opracowania zadań z rozmów kwalifikacyjnych
Przygotowałem rozwiązania kilku zadań algorytmicznych z rozmów kwalifikacyjnych. Rozkładam je na czynniki pierwsze i pokazuję różne sposoby ich rozwiązania. Dołącz do grupy ponad 6147 Samouków, którzy jako pierwsi dowiadują się o nowych treściach na blogu, a prześlę je na Twój e-mail.
Nie chcę rozpoczynać świętej wojny. Niektórzy programiści bronią formatowania, do którego są przyzwyczajeni, jak niepodległości. Mam do tego bardziej pragmatyczne podejście. Używaj formatowania kodu. Niech IDE robi to za Ciebie, nie zastanawiaj się nad tym dopóki nie zacznie Ci ono przeszkadzać. Nie chcę się tu rozpisywać nad wyższością jednego formatowania nad drugim, to nie ma sensu. Istotne jest to, że brak formatowania kodu można traktować jako złą praktykę.
Moim zdaniem dobrym podejściem jest włączenie automatycznego formatowania kodu w IDE1. W zależności od tego jakiego IDE używasz ta akcja może być wykonywana na przykład przed każdym zapisem pliku czy przed każdym commit’em do repozytorium. Dzięki temu możesz w ogóle zapomnieć o formatowaniu i skupić się na innych rzeczach. IDE zrobi to za Ciebie.
Istotne jest to, żeby wszystkie osoby, które pracują w danym projekcie używały spójnego formatowania kodu. Wachlowanie się commit’ami, które polegają tylko na zmianach w formatowaniu kodu nie jest dobrym pomysłem. Formatowanie kodu to konwencja, która musi być ustalona wspólnie przez cały zespół i konsekwentnie stosowana.
Język Java pozwala na opuszczanie nawiasów { }
jeśli blok ma jedną linię. Tego typu konstrukcja może być na przykład użyta po warunku if
czy pętli. Proszę spójrz na przykład poniżej:
// incorrect
if (activeUser.isAnAdmin())
allowedActions.add(Action.DELETION);
System.out.println("Some log message");
Moim zdaniem to bardzo zła praktyka. Może prowadzić do trudnych do znalezienia błędów. Co jeśli tylko użytkownik, który jest administratorem powinien móc dokonywać modyfikacji? Ktoś mógłby wprowadzić drobną zmianę:
// incorrect
if (activeUser.isAnAdmin())
allowedActions.add(Action.DELETION);
allowedActions.add(Action.MODIFICATION);
System.out.println("Some log message");
Problem polega na tym, że taki fragment kodu powoduje, że każdy użytkownik mógłby wykonać modyfikację. Dlatego nawet przy jednoliniowych blokach należy używać nawiasów:
// correct
if (activeUser.isAnAdmin()) {
allowedActions.add(Action.DELETION);
}
System.out.println("Some log message");
Na początku mojej przygody z programowaniem pracowałem w Eurobanku. Nie zapomnę do końca życia strony w intranecie opisującej „kwiatki w kodzie”. Kwiatki w kodzie czyli radosną twórczość programistów, która po dłuższym zastanowieniu nie ma sensu. Dość dużą część tej strony zajmowały przykłady kodu z wyrażeniami logicznymi.
Proszę spójrz na kilka złych przykładów wraz z propozycjami jak można je poprawić:
// incorrect
boolean parameter = // ...
if (parameter == true) {
// ...
}
// correct
boolean parameter = // ...
if (parameter) {
// ...
}
Podobnie wyglądać może sytuacja z odwróceniem warunku
// incorrect
if (parameter == false) {
// ...
}
// correct
if (!parameter) {
// ...
}
Spotkałem się też z uzależnieniem wartości zwracanej od zmiennej typu boolean
:
// incorrect
if (parameter == true) {
return false;
}
else {
return true;
}
// correct
return !parameter;
Warunki logiczne często urastają do sporych potworków. Jeśli zauważysz jeden z nich, który ma zawsze taką samą wartość warto uprościć takie wyrażenie. Dzięki temu kod będzie bardziej czytelny. W przykładzie poniżej zakładam, że variableThatAlwaysIsNull
w wyniku różnych operacji zawsze ma wartość null
:
// incorrect
Object variableThatAlwaysIsNull = methodAlwaysReturningNull();
boolean someMagicFlag = // ...
if (variableThatAlwaysIsNull == null && someMagicFlag) {
// ...
}
// correct
boolean someMagicFlag = // ...
if (someMagicFlag) {
// ...
}
Spotkałem się też z kodem tego typu:
// incorrect
if (someMagicFlag) {
}
else {
// code to execute
}
Blok if
nie zawierał żadnej linijki. Kod do wykonania znajdował się wewnątrz bloku else
:
// correct
if (!someMagicFlag) {
// code to execute
}
Przykłady tego typu można mnożyć. Ważne, żeby zwracać uwagę na wyrażenia logiczne – bardzo często można je uprościć. Jeśli nie znasz praw De Morgana, to najwyższy czas je poznać ;).
Jakiś czas temu pisałem o regule Don’t Repeat Yourself. Można ją zastosować na wielu poziomach. Jednym z nich jest kod źródłowy programu. Duplikacja w kodzie jest zła. Należy ją eliminować (jestem gorącym zwolennikiem usuwania kodu). Poniższy przykład pokazuje duplikację w bardzo wąskim zakresie:
// incorrect
class MagicNumber {
private int value;
public boolean isEven() {
return value % 2 == 0;
}
public boolean isOdd() {
return value % 2 == 1;
}
}
// correct
class MagicNumber {
private int value;
public boolean isEven() {
return value % 2 == 0;
}
public boolean isOdd() {
return !isEven();
}
}
Moim zdaniem unikanie zbędnych zagnieżdżeń jest dobre. Mam tu na myśli pomijanie bloku else
, jeśli kod wewnątrz bloku if
na pewno zakończy działanie metody. Może się tak stać na przykład w sytuacji kiedy wewnątrz bloku if
znajduje się return
:
// „incorrect”
if (someFlag) {
// return/throw/break/continue
}
else {
// something else
}
Moim zdaniem pomięcie else
poprawia czytelność:
// correct
if (someFlag) {
// return/throw/break/continue
}
// something else
Metody statyczne są przypisane do klasy. Moim zdaniem warto o tym pamiętać i wywoływać metody statyczne posługując się klasą a nie jej instancją:
// incorrect
SomeClass instance = new SomeClass();
instance.staticMethod();
// correct
SomeClass.staticMethod();
import *
Kolejny subiektywny punkt. Nie podchodzą mi klasy/metody statyczne importowane przy pomocy *
. Pewnie wynika to trochę z filozofii jaką proponuje Python – explicit is better than implicit.
// incorrect
import static java.lang.Math.*;
// correct
import static java.lang.Math.sqrt;
import static java.lang.Math.pow;
Problemy i złe praktyki na poziomie poszczególnych plików to czubek góry lodowej. Pod spodem kryją się większe problemy. Problemy związane z podejściem do samego projektu.
Piszesz kod bez używania systemu kontroli wersji? Robisz błąd. System kontroli wersji jest narzędziem niezbędnym w pracy każdego programisty. Polecam Ci Git’a, który moim zdaniem jest standardem w branży.
Piszesz kod bez testów jednostkowych? Robisz błąd. Moim zdaniem automatyczne testy jednostkowe w wielu przypadkach są niezbędne. Nie będę się tu rozwodził nad tematyką testów. Zachęcam Cię do przeczytania artykułów:
Na ten temat powstają mądre książki. Dobrym początkiem będzie zapoznanie się z zasadami SOLID i ich świadome stosowanie w pracy z kodem.
W idealnym świecie zbudowanie projektu powinno składać się z dwóch etapów:
Oba etapy powinny działać niezależnie od środowiska programisty. Drugi punkt rozwiązywany jest przez narzędzia takie jak Maven, Make, Rake, Gradle, Ant, Grunt itp. Jeśli do tej pory nie udało Ci się pracować z narzędziami tego typu zachęcam Cię do zajrzenia do artykułów opisujących Gradle:
Organizacja plików w projekcie jest ważna. Podobnie jak z nazewnictwem czy formatowaniem kodu istnieje pewna konwencja, która pozwala na szybkie zorientowanie się w strukturze projektu. Niejako powiązane z tym tematem jest używanie narzędzie wspomagającego budowanie projektu, które „narzucają” używanie pewnych konwencji. Standardową strukturę projektu opisałem we wstępie do Gradle.
Historia w repozytorium jest od tego, żeby pamiętać co działo się w projekcie. Fragmenty kodu w komentarzu, które „może kiedyś się przydadzą” moim zdaniem powinny od razu wylecieć w kosmos. Nie są potrzebne, jedynie zaciemniają obraz.
Kilka poniższych podpunktów opisuje różne przypadki, które można podsumować w jednym zdaniu: nie jest sztuką napisać dużo kodu, sztuką jest napisać jak najmniej czytelnego i zrozumiałego kodu, który robi to samo. Jeśli masz możliwość usunięcia czegoś, co nie jest używane zrób to! :) Mniej kodu oznacza mniej potencjalnych błędów. Mniej kodu, to niższy koszt jego utrzymania2.
Często jest tak, że fragmenty martwego kodu narastają z czasem – wynikają z kilku zmian wprowadzonych na przestrzeni życia projektu. Odwaga do usuwania danej linijki kodu jest odwrotnie proporcjonalna do jej wieku ;).
Ten punkt jest powiązany z flagami, które poruszałem wcześniej. Po uproszczeniu warunków logicznych możesz czasami zauważyć, że dotarcie do pewnych fragmentów kodu jest po prostu niemożliwe. W podstawowych przypadkach IDE potrafi pokazać takie fragmenty kodu jako martwe. Dobrym pomysłem jest usunięcie śmieci tego typu.
Widzisz metodę, która ma nieużywany parametr? Zastanów się czy możesz go usunąć. Jeśli tak, to wiesz co masz zrobić ;). Podobną regułę trzeba stosować przy atrybutach klas.
Zwróć szczególną uwagę na zmianę sygnatury metody. Tego typu zmiany mogą prowadzić do „dziwnych zachowań”. Mam tu na myśli sytuację, w której metoda nadpisywała inną w klasie bazowej. Tu drobne ćwiczenie dla Ciebie – czym może skończyć się takie usunięcie parametru?
Usuwanie atrybutów, to też coś co wymaga pewnej analizy. W zależności od modyfikatora dostępu może, ale nie musi, łamać kompatybilność wsteczną.
Nie zrozum mnie źle. Uważam, że nieduże metody są dobre. Jednak także i tutaj trzeba zachować zdrowy rozsądek. Proszę spójrz na przykład poniżej, używa on klasy Math
:
// incorrect
double someVeryImportantCalculation(double argument0, double argument1) {
return argument0 + sqrt(argument1);
}
double sqrt(double argument) {
return Math.sqrt(argument);
}
Moim zdaniem w tym przypadku wprowadzenie metody sqrt
nic nie wnosi. Równie dobrze w miejscu jej wywołania można byłoby użyć Math.sqrt
.
// correct
double someVeryImportantCalculation(double argument0, double argument1) {
return argument0 + Math.sqrt(argument1);
}
Widziałem przypadki, w których metoda wywoływana dla efektów ubocznych3 zwracała wartość. Ta zwrócona wartość nie była w ogóle wykorzystywana. Moim zdaniem warto uprościć taką metodę usuwając wartość zwracaną:
// incorrect
class User {
String login() {
try {
callingExternalServiceToLogin();
}
catch (LoginException e) {
// handling exception
}
return "logged in";
}
}
// correct
class User {
void login() {
try {
callingExternalServiceToLogin();
}
catch (LoginException e) {
// handling exception
}
}
}
Tutaj nie mam przykładu z zadań na blogu, jednak nadal warto wspomnieć o tym błędzie. W świecie programistów panuje przekonanie, że „przedwczesna optymalizacja jest źródłem całego zła”4. Podpisuję się pod tym obiema rękami. Kompilator Java jest na tyle zaawansowany, że potrafi zrobić cuda, tak żeby nasz kod był bardziej wydajny.
Zacznij od pisania zrozumiałego i czytelnego kodu. Dopiero gdy zauważysz, że pojawiają się problemy wydajnościowe wprowadzaj optymalizacje. Istotne jest żeby wprowadzać takie zmiany na podstawie twardych dowodów – przeprowadzonych testów wydajnościowych.
Jest to ważne, bo może zdarzyć się tak, że intuicja nawet doświadczonych programistów nie sprawdza się w praktyce. Przez co wprowadzona optymalizacja ma znikomy (zerowy?) wpływ na wydajność, a sprawia, że kod jest zupełnie niezrozumiały.
Im mniej obiektów, tym mniej zajętej pamięci. Jeśli możesz użyć obiektu wielokrotnie zrób to, nie ma sensu tworzyć nowej instancji dla każdego wywołania. Tutaj sprawa trochę się komplikuje. Wszystko przez wątki i współdzielenie instancji pomiędzy nimi. Jeśli instancja obiektu będzie współdzielona pomiędzy wątkami należy upewnić się, że kod jej klasy napisany jest w wielowątkowo bezpieczny sposób.
Uproszczony przykład tworzenia nadmiarowej instancji:
// incorrect
class UserInput {
public String get(String prompt) {
System.out.println(prompt);
Scanner scanner = new Scanner(System.in);
return scanner.next();
}
}
// correct
class UserInput {
private Scanner scanner = new Scanner(System.in);
public String get(String prompt) {
System.out.println(prompt);
return scanner.next();
}
}
Znajomość bibliotek i API przychodzi z czasem. Nie ma sensu uczyć się tego na pamięć. Poniżej zebrałem najczęściej spotykane błędy powiązane z klasami dostarczonym wraz z JDK.
System.in
, System.out
, System.err
Wspomniany wyżej Scanner
jest bardzo często używany do pobierania danych od użytkownika. Jednym ze sposobów utworzenia instancji tej klasy jest przekazanie jej instancji InputStream
. Możesz na przykład użyć System.in
. Proszę spójrz na przykład poniżej:
// incorrect
public static void main(String[] args) {
try(Scanner s = new Scanner(System.in)) {
System.out.println(s.next());
}
try(Scanner s = new Scanner(System.in)) {
System.out.println(s.next());
}
}
Ten kod jest zły z dwóch powodów. Pierwszy to wyżej wspomniane tworzenie dwóch instancji klasy Scanner
, w tym przypadku spokojne wystarczy jeden obiekt i jego użycie wiele razy. Drugim, poważniejszym błędem jest zamykanie System.in
. Dzieje się tak, ponieważ po wyjściu z bloku try with resources na instancji s
wywoływana jest metoda close
. Powoduje to zamknięcie System.in
. W ramach ćwiczenia uruchom powyższy kod i zobacz jaki będzie jego efekt.
Nie jest to dobra praktyka. To wirtualna maszyna Javy otwiera ten strumień i to ona jest odpowiedzialna za jego zamknięcie. Sprawa wygląda podobnie w przypadku strumieni System.out
czy System.err
.
Jeśli chcesz przeczytać więcej o stdout, stderr i stdin w trochę innym kontekście zapraszam do przeczytania artykułu opisującego początki pracy z linią poleceń.
Poniżej możesz zobaczyć poprawiony fragment kodu:
// correct
public static void main(String[] args) {
Scanner s = new Scanner(System.in);
System.out.println(s.next());
System.out.println(s.next());
}
Java pozwala tworzyć programy, które mogą być uruchamiane na różnych systemach operacyjnych. Żeby programy te działały w pełni poprawnie trzeba brać pod uwagę różnice, które występują pomiędzy nimi.
Sztandarowym przykładem jest tutaj znak końca linii. W zależności od systemu operacyjnego inny ciąg znaków odpowiedzialny jest za łamanie linii. Poniższy przykład pokazuje błąd i jego rozwiązanie:
// incorrect
System.out.println("This is a list:\n- item.");
// correct
System.out.println("This is a list:" + System.lineSeparator() + "- item.");
Wyrażenia regularne i bardziej zaawansowane wyrażenia regularne były już poruszane na blogu.
Tutaj chciałbym zwrócić na jeden drobny szczegół. Proszę rzuć okiem na kod poniżej:
// incorrect
class Postcode {
public static boolean isValid(String postcode) {
Pattern postcodePattern = Pattern.compile("^\\d{2}-\\d{3}$");
Matcher matcher = postcodePattern.matcher(postcode);
return matcher.find();
}
}
Kompilacja wyrażenia regularnego jest procesem długotrwałym. Jeśli jest taka możliwość to warto wykonywać tę czynność tylko raz:
// correct
class Postcode {
public static final Pattern PATTERN = Pattern.compile("^\\d{2}-\\d{3}$");
public static boolean isValid(String postcode) {
Matcher matcher = PATTERN.matcher(postcode);
return matcher.find();
}
}
Java dostarcza cały szereg gotowych klas wyjątków. Czasami nie ma sensu tworzenie własnego dedykowanego wyjątku – warto użyć jednego z istniejących. Dobrym przykładem jest użycie wyjątku IllegalArgumentException
jeśli chcesz zasygnalizować niepoprawny argument.
Dodatkowo ważne jest żeby rzucać wyjątki, które pasują do danej sytuacji. Na przykład rzucenie wyjątku IllegalStateException
w sytuacji gdy podano błędny argument nie jest najlepszym rozwiązaniem.
java.util.Date
i spółkaGdzie tylko się da omijaj stare API do zarządzania datami szerokim łukiem. Na przykład instancje java.util.Date
nie są wielowątkowo bezpieczne, API jest zagmatwane, obsługa stref czasowych wymaga więcej pracy.
Skup się na poznaniu LocalDateTime
i jej podobnych.
Konstrukcje języka nie są związane z API a składnią jaką język oferuje. Java ewoluuje jak każdy język. W kolejnych wersjach wprowadza nowe elementy. Warto z nich korzystać. Za przykład mogą tu posłużyć wyrażenia lambda, wyrażenia switch
, zmienne lokalne przy użyciu var
, konstrukcja try with resources i tak dalej ;).
Do tej pory nie nazwałem tego wprost. Wprowadzanie zmian, które nie modyfikują zachowania programu to tak zwana refaktoryzacja. Zacznij od przeczytania czym jest refaktoryzacja w artykule na Wikipedii. Później możesz sięgnąć po książkę Refactoring autorstwa Martin’a Fowler’a. Pierwsza edycja zawiera przykłady w Javie, druga w JavaScript.
Możesz też rzucić okiem na dość stary dokument opisujący konwencja nazewnicza w języku Java. Opisuje on też zalecane formatowanie kodu.
W treści artykułu wspomniałem o prawach De Morgana. To podstawa, jak już je poznasz warto poczytać więcej o algebrze Boole’a i wzorach pozwalających na upraszczanie wyrażeń logicznych.
Moją motywacją do napisania tego artykułu było zebranie w jednym miejscu błędów i propozycji ich rozwiązania. Temat bynajmniej nie jest wyczerpany. Większość z tych punktów można rozbudować podając więcej przykładów.
Jednak nawet w obecnej formie artykuł pokazał Ci większość klas „podstawowych błędów”. Po jego lekturze wiesz jak można je poprawić. Stosując się do zaleceń, które tu zebrałem Twój kod będzie na pewno wyższej jakości. Z góry gratuluję ;).
Jeśli znasz kogoś dla kogo ten artykuł byłby pomocny proszę podziel się linkiem. Dzięki temu pomożesz mi dotrzeć do nowych Czytelników, za co od razu bardzo dziękuję!
Jeśli nie chcesz pomiąć kolejnych artykułów polub Samouczka na Facebooku i dopisz się do samouczkowego newslettera. To tyle na dzisiaj, trzymaj się i do następnego razu!
Potrafię sobie wyobrazić wyjątki od tej reguły. Załóżmy, że pracujesz nad projektem, który nie jest pierwszej młodości. Znajdują się w nim pliki mające kilka lat i kilka tysięcy linii. Musisz poprawić błąd, który sprowadza się do zmiany kilku linijek. Łączenie tej zmiany z formatowaniem całego pliku przeważnie nie jest dobrym pomysłem. ↩
Jak napisałem wcześniej – zakładam, że kod jest napisany w sposób czytelny i zrozumiały. Nie chodzi mi tu o sytuację, w której używasz jednoliterowych nazw metod, żeby „było mniej kodu”. ↩
Abstrahując od tego czy metody posiadające efekty uboczne są w porządku czy nie. ↩
Cytat pochodzi z książki autorstwa Donalda Knuth’a. ↩