Paradygmaty programowania/Wykład 15: Inne paradygmaty warte wspomnienia

From Studia Informatyczne

Na zakończenie rozważań o paradygmatach programowania chcemy przedstawić krótkie zestawienie ich charakterystycznych cech. Korzystając z tej sposobności, chcemy również wspomnieć o kilku paradygmatach, których nie omawialiśmy. Powiemy m.in. o programowaniu współbieżnym i o programowaniu sterowanym zdarzeniami.

Spis treści

Wstęp

Przypomnijmy, jak zdefiniowaliśmy paradygmat programowania w pierwszym wykładzie. Otóż jest to zbiór mechanizmów, jakich programista używa, pisząc program oraz jak ów program jest następnie wykonywany przez komputer. Można zatem powiedzieć, że paradygmat programowania to ogół oczekiwań programisty wobec języka programowania i komputera, na którym będzie działał program.

Wykład skupił się na czterech głównych paradygmatach: imperatywnym, obiektowym, funkcyjnym i logicznym. Te cztery wybraliśmy jako „sztandarowe”, ukazujące główne nurty myślenia o programowaniu — choć niekoniecznie najbardziej rozpowszechnione. O ile rozpowszechnienie programowania imperatywnego i obiektowego jest ogromne, to czyste programowanie funkcyjne, a tym bardziej logiczne, jest już rzadsze. Teraz, przy okazji podsumowania, warto więc powiedzieć kilka słów o kilku innych paradygmatach.

Podsumowanie sztandarowej czwórki

Programowanie imperatywne

Chcąc najkrócej scharakteryzować programowanie imperatywne, moglibyśmy napisać „zrób najpierw to, a potem tamto”. Mamy zatem sekwencję poleceń zmieniających krok po kroku stan maszyny, aż do uzyskania oczekiwanego wyniku — czyli mamy stan będący funkcją czasu. Ten sposób patrzenia na programy związany jest ściśle z budową sprzętu komputerowego o architekturze von Neumanna, w którym poszczególne instrukcje to właśnie polecenia zmieniające ów globalny stan.

Imperatywne języki programowania posługują się abstrakcjami bliskimi architekturze von Neumanna, np. zmienne są abstrakcją komórek pamięci. Naturalną abstrakcją są tu też procedury (będące, notabene, zasadniczym elementem „podparadygmatu” proceduralnego). Najważniejsze — w swoim czasie — języki imperatywne to Fortran, Cobol, Pascal i C.

Programowanie obiektowe

W programowaniu obiektowym program to zbiór porozumiewających się ze sobą obiektów, czyli jednostek zawierających określone dane i umiejących wykonywać na nich określone operacje. Najważniejsze są tu dwie cechy: po pierwsze, powiązanie danych (czyli stanu) z operacjami na nich (czyli poleceniami) w całość, stanowiącą odrębną jednostkę — obiekt; po drugie, mechanizm dziedziczenia, czyli możliwość definiowania nowych, bardziej złożonych obiektów, na bazie obiektów już istniejących.

Z tych dwóch cech bierze się zapewne wielki sukces paradygmatu obiektowego. Umożliwia on bowiem modelowanie zjawisk rzeczywistego świata w uporządkowany, hierarchiczny sposób — od idei do szczegółów technicznych. Wśród najważniejszych języków należy wymienić C++ (chociaż nie jest to język czysto obiektowy) i Javę; ze względów historycznych i poznawczych należałoby dodać Simulę 67 i Smalltalk.

Programowanie funkcyjne

Tu po prostu składamy i obliczamy funkcje, w sensie podobnym do funkcji znanych z matematyki. Nie ma stanu maszyny — nie ma zmiennych mogących zmieniać wartość. Nie ma zatem „samodzielnie biegnącego” czasu, a jedynie zależności między danymi. Nie ma efektów ubocznych. Można by wręcz sądzić, że programowanie funkcyjne zdefiniowane zostało jako poważne ograniczenie paradygmatu imperatywnego... Rzeczywiście, do pewnego stopnia zwykłe języki imperatywne zawierają w sobie „podjęzyk” funkcyjny (można przecież tworzyć, składać i wywoływać funkcje). Prawdziwe języki funkcyjne oferują jednak znacznie więcej, m.in. rekurencyjne struktury danych i możliwość operowania funkcjami wyższego rzędu.

Programowanie funkcyjne nie doczekało się takiej popularności jak dwa poprzednie paradygmaty, choć są dziedziny, gdzie jego pozycja jest przyzwoita (np. język Erlang używany w telekomunikacji oraz język K używany do obliczeń finansowych). Najsłynniejsze języki funkcyjne żyły lub żyją przede wszystkim w środowiskach akademickich: Lisp, Scheme (potomek Lispu), ML, Ocaml (potomek ML-a), Miranda, Haskell.

Programowanie w logice

Podobnie jak w programowaniu funkcyjnym, nie „wydajemy rozkazów”, a jedynie opisujemy, co wiemy i co chcemy uzyskać (z tego powodu języki funkcyjne i logiczne nazywa się łącznie językami deklaratywnymi). Na program składa się zbiór zależności (przesłanki) i pewne stwierdzenie (cel). Wykonanie programu to próba udowodnienia celu w oparciu o podane przesłanki, a więc pewien rodzaj automatycznego wnioskowania; obliczenia wykonywane są niejako „przy okazji” dowodzenia celu.

Język programowania w logice (a ściślej — jego interpreter) to właściwie system automatycznego dowodzenia twierdzeń, działający w oparciu o nieco uproszczony rachunek predykatów. Kluczowym pojęciem jest tu rezolucja, realizowana m.in. przy pomocy unifikacji i nawrotów. Zastosowania programowania w logice obejmują przede wszystkim sztuczną inteligencję (np. systemy ekspertowe, rozpoznawanie obrazów) i przetwarzanie języka naturalnego. Językiem, który uzyskał największą popularność, jest Prolog. Historycznie ważny jest również Planner — wcześniejszy i bardziej złożony od Prologu.

Inne paradygmaty

Oczywiście „wielka czwórka” to nie wszystko. Warto wspomnieć jeszcze o kilku paradygmatach, które można by określić mianem niszowych bądź cząstkowych. Chodzi nam o to, że dotyczą one albo dość szczególnych rodzajów programowania (np. programowanie sterowane przepływem danych), albo odnoszą się tylko do niektórych cech programowania (np. programowanie strukturalne).

Programowanie strukturalne

O programowaniu strukturalnym wspominamy z kronikarskiego obowiązku, jest to bowiem bardzo dobrze znany i powszechnie stosowany „podparadygmat” programowania imperatywnego, czy ściślej — proceduralnego. Chodzi w nim o tworzenie programów z kilku dobrze zdefiniowanych konstrukcji takich jak instrukcja warunkowa if-then-else i pętla while, za to bez skoków (go to). Powinno to sprzyjać pisaniu programów przejrzystych, łatwych w rozumieniu i utrzymaniu.

Ściślej, Dijkstra proponował użycie tylko trzech rodzajów struktur sterujących:

  • Sekwencja (lub konkatenacja) — czyli po prostu wykonanie instrukcji w określonej kolejności. W wielu językach rolę „operatora konkatenacji instrukcji” spełnia niepozorny średnik...
  • Wybór — czyli wykonanie jednej z kilku instrukcji zależnie od stanu programu. Przykładem jest if-then-else i switch/case.
  • Iteracja, czyli powtarzanie instrukcji tak długo, jak długo spełniony (lub niespełniony) jest dany warunek. Chodzi oczywiście o pętle, np. while, repeat-until, for itp.


Programowanie strukturalne


Programowanie niestrukturalne

Powyższe struktury stosuje się do „małych” jednostek programu, złożonych z elementarnych instrukcji, czyli np. podstawień, wywołań procedur lub instrukcji wejścia-wyjścia. „Duże” jednostki powinny być — wedle zasad programowania strukturalnego — rozbite na mniejsze (funkcje, procedury, bloki itp.), tak by można było zrozumieć poszczególne fragmenty bez rozumienia całości.

Z programowaniem strukturalnym powszechnie kojarzy się też niechęć do instrukcji skoku. Rzeczywiście Dijkstra był bardzo przeciwny instrukcji skoku, uważając, że zaciemnia ona strukturę programu i w rezultacie pogarsza jego jakość. Oponenci Dijkstry twierdzili natomiast, że w pewnych sytuacjach użycie skoku jest naturalne i bardziej czytelne, niż usilne unikanie go za pomocą dodatkowych zmiennych, warunków i testów. Na dwóch biegunach znalazły się w swoim czasie języki Basic (tylko skoki, żadnych „przyzwoitych” struktur) i Pascal (modelowy język strukturalny, choć i w nim skoki są dopuszczalne...).

Do dzisiaj dyskusja nad skokami zdążyła już gruntownie ostygnąć. Na ogół wszyscy się zgadzają, że programowanie strukturalne jest pożyteczne, choć w pewnych sytuacjach użycie skoku wcale nie zasługuje na potępienie. Rozpowszechniły się konstrukcje językowe, nieco bardziej liberalne niż pierwotne idee Dijkstry, zapewniające programiście wygodę bez poświęcania przejrzystości programu. Przykładowo:

  • Instrukcja return pozwala pisać funkcje z wieloma punktami wyjścia.
  • Instrukcja break pozwala opuścić pętlę w dowolnym miejscu.
  • Obsługa wyjątków daje nam możliwość zajęcia się sytuacjami wyjątkowymi bez mnożenia zbędnych bytów (zmiennych, warunków itp.).

Praktycznie wszystkie języki programowania — nawet te pierwotnie niestrukturalne, np. Fortran i Basic — pozwalają dzisiaj pisać programy strukturalne, choć tylko nieliczne zabraniają stosowania skoków. Idea programowania strukturalnego odniosła więc sukces dydaktyczny (wielki) i komercyjny (nieco mniejszy).

Programowanie sterowane przepływem danych

Jest to w praktyce paradygmat „niszowy”, odnoszący się raczej do szczególnych sytuacji, a nie do programowania w ogóle — mimo iż jego pomysłodawcy uważali, że to właśnie programowanie sterowane przepływem danych powinno być jak najbardziej naturalne i powszechne. Chodzi o to, by program postrzegać jako graf operacji, między którymi przepływają dane. Moment wykonania danej operacji nie jest więc zależny od liniowej sekwencji instrukcji, lecz od dostępności danych — a przecież w końcu to dane są najważniejsze... Ten pomysł można w pewnym uproszczeniu zobrazować przez analogię do linii produkcyjnej. Przy linii znajdują się stanowiska, gdzie wykonywane są pewne operacje. Operacja jest wykonywana, gdy tylko przy stanowisku pojawi się przetwarzany przedmiot (czyli dane).

Zauważmy, że taki model bardzo dobrze nadaje się do równoległego wykonywania programu na wielu procesorach. Rozkład zadań na procesory uzyskujemy tu automatycznie, bez konieczności pisania dodatkowego, uciążliwego kodu rozdzielającego zadania. Z myślą o programowaniu równoległym powstały zresztą niektóre ważne języki, np. Sisal wykorzystywany do programowania superkomputerów Cray i język G, będący częścią środowiska LabView i służący do programowania aparatury pomiarowej.

Najbardziej rozpowszechnionym jednak przykładem programowania sterowanego przepływem danych jest arkusz kalkulacyjny. Zmiana wartości w komórce arkusza powoduje automatyczne przeliczenie zawartości innych komórek, zgodnie z grafem zależności pomiędzy nimi. W szczególności, zmiana jednej komórki może spowodować kaskadę zmian w komórkach powiązanych łańcuchem zależności.

Drugi przykład to rozwiązania stosowane we współczesnych procesorach. Procesor posiada kilka jednostek arytmetyczno-logicznych i stara się je wykorzystywać, gdy tylko są dostępne odpowiednie dane, niekoniecznie zgodnie z oryginalną kolejnością instrukcji w programie. Wymaga to skomplikowanego śledzenia i oznaczania danych pomiędzy operacjami. Proces ten można sobie wyobrazić jako „okienko” podążające za instrukcjami programu; instrukcje mieszczące się wewnątrz okienka są wykonywane w sposób sterowany przepływem danych, choć samo okienko przesuwa się zgodnie z przepływem sterowania w programie. Ze względu na złożoność tego procesu, w praktyce możliwe jest obsłużenie jedynie niewielkiej liczby jednostek arytmetyczno-logicznych (np. sześć jednostek w procesorach Pentium), a w okienku mieści się ograniczona liczba instrukcji (nieco ponad sto). Komputery stosujące tę technikę budowano już w latach 60-tych XX wieku; „pod strzechy”, czyli do mikroprocesorów, trafiła ona jednak dopiero w połowie lat 90-tych.

Trzeci przykład, bardzo skromny, to znane z systemu Unix potoki. Graf zależności między operacjami jest tu bardzo prosty (kreska, i to zazwyczaj niedługa), ale idea wykonywania operacji w miarę dostępności danych jest zachowana.

Programowanie sterowane zdarzeniami

Chodzi o programowanie, w którym zamiast zasadniczego nurtu sterowania mamy wiele drobnych programów obsługi zdarzeń, uruchamianych w chwili wystąpienia odpowiedniego zdarzenia. Zdarzenia mogą być wywoływane przez urządzenia wejścia-wyjścia (np. naciśnięcie klawisza, ruch myszką) lub przez same programy obsługi zdarzeń. Oprócz zbioru programów obsługi zdarzeń potrzebny jest też zarządca, który będzie je uruchamiał.

Przykłady programowania sterowanego zdarzeniami są znacznie częstsze niż można by sądzić. Każdy system operacyjny jest — do pewnego stopnia — sterowany zdarzeniami: reaguje na przerwania. Rolę zarządcy spełnia tu procesor komputera. Z kolei sam system operacyjny jest zarządcą w stosunku do uruchomionych pod jego kontrolą procesów.

Inny typowy przykład to serwery bazodanowe, w których procedury mogą być uruchamiane w odpowiedzi na ustalone zdarzenia.

Programowanie współbieżne

Tym razem mówimy o „nadparadygmacie”, gdyż chodzi o wykonywanie wielu zadań obliczeniowych w tym samym czasie. Istotą problemu jest koordynacja zadań, które komunikują się ze sobą i korzystają ze wspólnych zasobów, a tym samym są od siebie zależne. Pojęcie programowania współbieżnego jest ogólniejsze od programowania równoległego i od programowania rozproszonego. Równoległość oznacza równoczesne wykonywanie zadań przez wiele procesorów; współbieżność obejmuje ponadto podział czasu jednego procesora między wiele zadań — czyli to, z czym mamy do czynienia w praktycznie każdym systemie operacyjnym. O programowaniu rozproszonym mówimy, gdy mamy wiele procesorów połączonych siecią (ale nie wieloprocesorowy komputer).

Komunikacja między współbieżnymi zadaniami może być niejawna, ale najczęściej mamy do czynienia z jawną komunikacją przy użyciu jednego z dwóch podstawowych mechanizmów:

  • Wspólna pamięć: porozumiewanie się następuje przez zmianę wartości umówionych komórek pamięci. Zwróćmy uwagę na związek tego mechanizmu z paradygmatem imperatywnym.
  • Przekazywanie komunikatów: ta metoda wymaga w praktyce bardziej rozbudowanego wsparcia ze strony systemu operacyjnego, ale jest łatwiejsza do matematycznego okiełznania — co powinno sprzyjać poprawności programów.

Mimo że programowanie współbieżne liczy już praktycznie kilkadziesiąt lat (pierwsze współbieżne systemy operacyjne powstały na początku lat 60-tych XX wieku), znajduje ono słabe odbicie w językach programowania. Przez długie lata było ono głównie domeną twórców systemów operacyjnych, później pojawiło się w innych dziedzinach, np. w oprogramowaniu dla telekomunikacji, na potrzeby którego powstał wspominany już język Erlang, obejmujący obsługę współbieżnych procesów. Innym przykładem języka z wbudowaną mechanizmami obsługi współbieżności jest Ada. W dzisiejszych czasach duża część programowania współbieżnego „dzieje się” w Javie, choć wsparcie dla współbieżności w samym języku jest raczej elementarne. Tym niemniej to właśnie Java przyczyniła się wydatnie do rozpowszechnienia programowania współbieżnego.