Paradygmaty programowania/Wykład 2: Semantyka zmiennych

From Studia Informatyczne

Spis treści

Wstęp

Imperatywne języki programowania oferują abstrakcyjne mechanizmy na bazie architektury von Neumanna. Dwa podstawowe składniki tej architektury to procesor i pamięć. Określenie „abstrakcyjne mechanizmy” oznacza, że mamy do dyspozycji narzędzia pozwalające wykorzystać możliwości komputera bez zagłębiania się w szczegóły techniczne. Jednym z takich mechanizmów są zmienne. Stanowią one abstrakcję komórek pamięci: programista może przechowywać dane w pamięci, nie martwiąc się o techniczne szczegóły (np. przydział pamięci). Odpowiedniość między zmiennymi a komórkami pamięci może być bardzo bezpośrednia (np. dla zmiennych typu całkowitego) lub dość odległa (np. wielowymiarowe tablice). Każdą zmienną można jednak scharakteryzować za pomocą sześciu atrybutów: nazwa, adres, wartość, typ, okres życia i zakres widoczności. Zajmiemy się teraz po kolei wspomnianymi cechami, kładąc nacisk na sprawy mniej oczywiste (np. statyczny i dynamiczny zakres widoczności); kwestie oczywiste omówimy zdawkowo na początku wykładu.

Nazwa jest cechą rozmaitych bytów, nie tylko zmiennych. To, co musimy ustalić w odniesieniu do nazw, to technikalia nie mające większego związku z przyjętym paradygmatem programowania. A więc:

  • Jakie znaki są dozwolone w nazwach? Tradycyjnie są to litery, cyfry i zwykle znak podkreślenia. Ale np. najnowsza wersja języka Ada (2005) przewiduje użycie dowolnych znaków z zestawu Unicode.
  • Liczba dozwolonych znaków: współczesne języki nie nakładają istotnych ograniczeń. Warto jednak pamiętać, że ze względu na zgodność z zewnętrznymi zasobami systemu operacyjnego nie wszystkie znaki muszą być brane pod uwagę przy rozróżnianiu nazw.
  • Czy duże i małe litery są rozróżniane? System Unix i język C zapoczątkowały „modę” na rozróżnianie dużych i małych liter, ale użyteczność tej konwencji wydaje się wątpliwa.

Zmienne



Przypomnijmy najważniejsze informacje. Zmienna to w języku programowania abstrakcja komórek pamięci. Każda zmienna ma sześć istotnych atrybutów: nazwę, adres, wartość, typ, okres życia i zakres widoczności.

Nazwy

  • Już o nich mówiliśmy.
  • Nie każda zmienna ma nazwę: przydzielając pamięć za pomocą new (w języku C++) tworzymy zmienną bez nazwy (nazwę ma użyty wskaźnik, ale nie sama zmienna).

Adresy

  • Program może zawierać dwie zmienne o tej samej nazwie, nie mające ze sobą nic wspólnego, np. dwie zmienne lokalne w dwóch różnych podprogramach.
  • Uwaga: Słowa „podprogram” używamy w znaczeniu dowolnej jednostki kodu, która może być wywoływana. Podprogramami są zatem funkcje, procedury, metody itp.
  • Ta sama zmienna lokalna może mieć różne adresy w czasie różnych wywołań tego samego podprogramu.
  • Nie ma zatem jednoznacznej odpowiedniości między nazwą a adresem.
  • Bieżący adres zmiennej zwany jest l-wartością; to określenie bierze się stąd, że właśnie l-wartość jest potrzebna po lewej stronie instrukcji przypisania.


Problem aliasowania

  • Niekiedy do tej samej komórki pamięci można dotrzeć za pomocą dwóch różnych nazw.
  • Tak jest np. wtedy, gdy dwa wskaźniki ustawimy na ten sam adres.
  • Przykładowo: int x, *p, *q; p = &x; q = &x;
  • Sytuacja taka zwana jest aliasowaniem.
  • Aliasowanie może prowadzić do przeoczeń i niejasności w kodzie, lepiej więc go unikać.

Wartość

  • Jest to po prostu zawartość komórki pamięci związanej z daną zmienną.
  • Zwana niekiedy r-wartością (gdyż ją właśnie odczytujemy, gdy zmienna występuje po prawej stronie przypisania).

Typ

  • Typ to zbiór dopuszczalnych wartości, jakie zmienna może przyjmować.
  • Gdy mówimy o zmiennych w reprezentacji zmiennopozycyjnej, typ określa też precyzję, z jaką liczby są reprezentowane.
  • Z typem wiąże się również zbiór operacji dopuszczalnych dla danej zmiennej.
  • Szczegółowe omówienie typów znajduje się w odrębnym module.

Okres życia i zakres widoczności omówimy później.

Wiązania

Będziemy teraz mówili o różnych bytach i atrybutach, które w pewnym momencie zostają powiązane. Rzecz dotyczy bardzo rozmaitych „bytów” takich jak zmienna, operator, wywołania podprogramu i cech takich jak wartość, typ, adres. Przykładowo, deklaracja zmiennej powoduje związanie zmiennej z typem, zaś wykonanie instrukcji podstawienia powoduje związanie zmiennej z (nową) wartością.

Wiązanie może następować w różnych momentach. Oto garść przykładów:

  • W czasie projektowania języka programowania jego twórca wiąże gwiazdkę (znak) z operacją mnożenia.
  • W czasie implementowania języka na konkretnej maszynie (czyli w czasie projektowania kompilatora) typ int zostaje związany z zakresem liczb całkowitych dostępnym na tej maszynie.
  • W czasie kompilacji zmienna zostaje związana z zadeklarowanym dla niej typem.
  • W czasie ładowania programu do pamięci zmienna statyczna zostaje związana z konkretnym adresem w pamięci.
  • W czasie konsolidacji wywołanie funkcji bibliotecznej zostaje związane z kodem tej funkcji.
  • W czasie wykonania programu zmienna lokalna zostaje związana z przydzieloną jej na stosie pamięcią.

Rozważmy przykładowy fragment w języku C

     int x;
     ...
     x = x * 3;

Mamy tu do czynienia z następującymi wiązaniami:

  • Typ zmiennej x jest wiązany (z int) w czasie kompilacji.
  • Sam typ int jest wiązany z konkretnym zakresem liczb całkowitych w czasie projektowania kompilatora.
  • Gwiazdka jest wiązana z konkretnym działaniem arytmetycznym (dopiero) w czasie kompilacji. W tym przypadku nie wcześniej, gdyż konkretne znaczenie gwiazdki zależy od typu operandów; gdyby jeden z nich był typu float, to gwiazdka oznaczałaby mnożenie zmiennopozycyjne, a nie całkowite.
  • Wewnętrzna reprezentacja liczby 3 jest wiązana z pewnym układem bitów w trakcie projektowania kompilatora.
  • Wartość zmiennej x jest wiązana z konkretną liczbą w chwili wykonania podstawienia.


Wiązania dzielimy na dwie klasy. Wiązania statyczne to te, które następują przed wykonaniem programu i nie zmieniają się w trakcie jego działania. Wiązania dynamiczne to te, które następują lub zmieniają się w trakcie działania programu. Nie zajmujemy się wiązaniami sprzętowymi, które normalnie są dla nas niewidoczne, np. translacja adresów pamięci wirtualnej. Innymi słowy, wiązanie zmiennej statycznej z miejscem w pamięci uznajemy za statyczne, mimo że fizyczny adres tej zmiennej może zmieniać się na skutek zarządzania stronami pamięci wirtualnej. W praktyce określenie „statycznie” oznacza „w czasie kompilacji”, zaś „dynamicznie” — „w czasie wykonania programu”.

Wiązanie typu

Każda zmienna musi zostać związana z typem przed pierwszym użyciem w programie. Rozpatrujemy dwa aspekty tej sprawy: Jak określamy ów typ? Kiedy następuje wiązanie?

Jak określamy typ zmiennej?

  • We współczesnych językach zazwyczaj mamy jawną deklarację.
  • Bywają też deklaracje niejawne, np. pierwsze użycie zmiennej może stanowić deklarację.
  • W przypadku deklaracji niejawnych konwencja może określać typ. W Fortranie pierwsza litera nazwy wyznaczała typ zmiennej, chyba że zmienna została zadeklarowana jawnie (taka dowolność bywa myląca...). W Perlu „zasada pierwszego znaku” jest obligatoryjna.
  • Występuje także mechanizm wnioskowania o typie z kontekstu, np. w języku ML i Haskellu.

Kiedy następuje wiązanie?

  • Deklaracje dają wiązanie statyczne.
  • Przy wiązaniach dynamicznych, zmienna jest wiązana z typem przy pierwszym podstawieniu pod nią wartości, np. w PHP i JavaScripcie
  • Wiązanie dynamiczne gwarantuje elastyczność, ale ma dwie wady: jest kosztowne (trzeba dynamicznie sprawdzać typ) i utrudnia wykrywanie błędów (kompilator ma małe szanse wykryć niezgodność typów).

Dlaczego dynamiczne wiązanie typu jest kosztowne?

  • Zgodność typów musi być sprawdzana dynamicznie, czyli w trakcie wykonania programu.
  • To oznacza, że każda wraz z każdą zmienną trzeba przechowywać deskryptor opisujący jej typ.
  • Operacje na zmiennej są trudniejsze, gdyż wartości różnych typów mogą wymagać pamięci o zróżnicowanym rozmiarze
    i zróżnicowanych działań.
  • W praktyce język z dynamicznymi typami musi być interpretowany a nie kompilowany: w czasie kompilacji nie wiadomo, co będą zawierały zmienne, więc nie da się wygenerować odpowiedniego kodu.

Jak wygląda wnioskowanie o typie?

  • Posłużymy się przykładem języka funkcyjnego ML.
  • Rozważmy następujące przykładowe deklaracje funkcji:
     fun f(x) = 2.0 * 3.14 * x;
     fun g(x) = 2 * x;
     fun h(x) = x * x;
  • W funkcji f pojawiają się stałe zmiennopozycyjne, więc ML wnioskuje, że i parametr, i wynik funkcji są typu zmiennopozycyjnego (real).
  • Analogicznie w funkcji g ML wnioskuje, że parametr i wynik są typu całkowitego.
  • W funkcji h wiadomo tylko, że chodzi o wartości liczbowe (ze względu na występowanie operatora mnożenia). Jako „domyślny” typ liczbowy przyjmowany jest typ całkowity, przeto ten właśnie typ przypisywany jest parametrowi i wynikowi funkcji.
  • Jeśli chcielibyśmy, by funkcja h działała na liczbach zmiennopozycyjnych, wystarczyłoby jawnie wskazać typ real choćby w jednym miejscu, przy parametrze lub wyniku. Reszta zostałaby „otypiona” dzięki wnioskowaniu o typie.
     fun h(x) : real = x * x;
     fun h(x : real) = x * x;
     fun h(x) = (x : real) * x;
     fun h(x) = x * (x : real);

Wiązanie pamięci

Wprowadźmy parę pojęć związanych z wiązaniem pamięci

  • Okres życia zmiennej to czas, w którym jest ona związana z konkretnym miejscem w pamięci.
  • Alokacja (przydział) pamięci oznacza pobranie bloku pamięci odpowiedniej wielkości z puli wolnej pamięci i związanie go ze zmienną.
  • Dealokacja (zwolnienie) pamięci oznacza unicestwienie wiązania bloku pamięci ze zmienną i oddanie go do puli wolnej pamięci.
  • Okres życia zmiennej to zatem czas pomiędzy alokacją a dealokacją.

Rozróżniamy cztery kategorie zmiennych, związane z ich okresem życia

  • Statyczne — mówimy wówczas o zmiennych statycznych.
  • Dynamiczne na stosie — najczęściej mówi się o zmiennych automatycznych lub po prostu lokalnych (oczywiście lokalność odnosi się w istocie nie do okresu życia, lecz do zakresu widoczności).
  • Dynamiczne na stercie, jawne.
  • Dynamiczne na stercie, niejawne.

Zmienne statyczne

  • Wiązane z miejscem w pamięci przed rozpoczęciem wykonania programu; wiązanie to nie zmienia się w trakcie wykonania.
  • Zaleta: Efektywne, ze względu na bezpośrednie adresowanie.
  • Wada: Mało elastyczne, nie mogą być używane do obsługi wywołań rekurencyjnych.
  • Przykład: Zmienne globalne oraz zmienne zadeklarowane jako static w języku C.
  • Uwaga: W językach takich jak C++, C# i Java deklaracja zmiennej z użyciem static pojawiająca się wewnątrz definicji klasy nie oznacza zmiennej statycznej w powyższym rozumieniu, a jedynie zmienną klasową (wspólną dla całej klasy).

Zmienne dynamiczne na stosie

  • Wiązane z pamięcią w chwili, gdy wykonanie programu dociera do ich deklaracji.
  • Pamięć przydzielana na stosie.
  • Pamięć zwalniana, gdy kończy się wykonanie bloku zawierającego daną zmienną.
  • Dla typowych zmiennych prostych (całkowite, zmiennopozycyjne) wszystkie atrybuty z wyjątkiem pamięci są wiązane statycznie.
  • Zalety: Mogą być używane w wywołaniach rekurencyjnych.
  • Wady: Mniejsza efektywność ze względu na pośrednie adresowanie, narzut związany z alokacją i dealokacją, brak „historii” (każde wywołanie podprogramu tworzy nową instancję zmiennych).
  • Przykład: Zmienne lokalne w funkcjach w języku C i w metodach w Javie.

Zmienne dynamiczne na stercie, jawne

  • Alokowane przez programistę w trakcie wykonania programu za pomocą jawnych poleceń, np. new, malloc.
  • Dealokowane również jawnie (w C i C++ za pomocą free i delete) lub niejawnie poprzez mechanizm odśmiecania (Java, C#).
  • Nie mają nazwy; dostępne są poprzez wskaźnik lub referencję.
  • Zalety: Mogą być używane do tworzenia dynamicznych struktur danych, np. list wiązanych i drzew.
  • Wady: Niska efektywność z powodu pośredniego trybu adresowania i skomplikowanego zarządzania stertą. Także duże ryzyko nadużyć ze strony nieostrożnego programisty.
  • Uwaga: W poniższej sytuacji wiązanie typu jest statyczne; dynamiczne jest natomiast wiązanie pamięci.
     int *p;
     p = new int;
     ...
     delete p;

Zmienne dynamiczne na stercie, niejawne

  • Alokowane i dealokowane niejawnie w trakcie wykonania programu w chwili wykonania podstawienia.
  • Przykład: Napisy i tablice w Perlu.
  • Zalety: Elastyczność posunięta do granic.
  • Wady: Wysoki koszt, związany z dynamicznym przechowywaniem atrybutów. Trudne wykrywanie błędów.

Sprawdzanie zgodności typów

Zgodność typów będziemy rozważali w szerokim sensie. Podprogramy będziemy traktowali jako operatory, których operandami są parametry. Instrukcję przypisania będziemy uważali za operację dwuargumentową, której operandami są lewa i prawa strona przypisania.

Sprawdzanie zgodności typów to sprawdzenie, czy typy operandów są odpowiednie. Określenie „odpowiedni (lub zgodny) typ” oznacza typ bezpośrednio dozwolony w danym kontekście lub typ, który jest dozwolony po zastosowaniu niejawnej konwersji narzuconej przez reguły języka programowania. Błąd typu to użycie operatora z operandem nieodpowiedniego typu. Wspomniane wyżej automatyczne, niejawne konwersje wykonywane są przez kod wygenerowany przez kompilator.

Przykład: W poniższym fragmencie wartość zmiennej j jest automatycznie zamieniana z typu int na float i wykonywane jest dodawanie zmiennopozycyjne.

     float x, y; int j; x = y + j;

Zastanówmy się, kiedy sprawdzanie zgodności typów może być statyczne. Jeśli wiązanie typów jest statyczne, to sprawdzanie zgodności typów na ogół także może być statyczne, tzn. robione w czasie kompilacji. Istnieją jednak wyjątki, np. unie, które mogą zawierać wartości różnych typów w tym samym miejscu pamięci. Jeśli zatem odwołania do pól unii mają być sprawdzane pod względem zgodności typów, to musi się to dziać dynamicznie, tzn. w trakcie wykonania programu. Zauważmy, że unie trudno właściwie nazwać wyjątkiem: unie są przykładem „nieprawdziwego” wiązania statycznego typu. Jeśli natomiast wiązanie typów jest dynamiczne, to sprawdzanie zgodności typów musi być dynamiczne.

Języki silnie typowane

Język nazywamy silnie typowanym, jeśli błędy typu są w nim zawsze wykrywane. Obejmuje to również dynamiczne sprawdzanie pól w uniach. Oczywistą zaletą silnego typowania jest możliwość wykrywania wielu pospolitych błędów. Wiele języków jest „prawie” silnie typowanych. Bliskie tego są Ada, C# i Java —odstępstwem jest w nich możliwość wykonania jawnej konwersji typów. Pascal nie jest silnie typowany ze względu na unie, występujące tu pod nazwą wariantów w rekordzie, choć poza tym niewiele brakuje... Natomiast C i C++ zdecydowanie nie są silnie typowane. Można w nich np. uniknąć sprawdzania typów parametrów, nie mówiąc już o polach unii...

Jaki jest związek silnego typowania z niejawnymi konwersjami? Występowanie w języku niejawnych konwersji może istotnie osłabić sens silnego typowania. Konwersje te powodują, że np. błędne podstawienia mogą formalnie przestać być błędami typu. Silne typowanie chcemy przecież mieć po to, by wykrywać jak najwięcej błędów. Zatem silne typowanie bez konwersji sprzyja niezawodności (kosztem wygody programisty). Przykładowo, zasady niejawnych konwersji (przede wszystkim) sprawiają, że sprawdzanie zgodności typów w Adzie jest bardziej skuteczne niż w Javie; to z kolei jest bardziej skuteczne niż w C++.

Jak konkretnie zdefiniować zgodność typów?

W zarysie już to zrobiliśmy... Rzecz nie jest jednak tak oczywista, jak z pozoru wygląda. Szczegółowe zasady zgodności typów mają wpływ na projektowanie i używanie typów. Zwykle rozróżniamy dwa rodzaje zgodności typów — zgodność nazwy i zgodność struktury — które omawiamy poniżej. Zajmujemy się też pokrótce podtypami i typami pochodnymi, jako że pojęcia te w naturalny sposób wiążą się z kwestią zgodności typów.

Zgodność nazwy

  • Dwie zmienne uznajemy za zgodne co do typu, jeśli zostały zdefiniowane w tej samej deklaracji lub jeśli do ich zadeklarowania użyto tej samej nazwy typu.
  • Tak rozumiana zgodność typów jest łatwa w implementacji, ale bardzo restrykcyjna.
  • Przykład: Podzakresy typu całkowitego nie są z nim zgodne.

Zgodność struktury

  • Dwie zmienne uznajemy za zgodne co do typu, jeśli mają taką samą strukturę.
  • Tu trzeba odpowiedzieć na szereg szczegółowych pytań...
  • Czy np. rekordy o takiej samej strukturze, ale innych nazwach pól, są zgodne? Czy tablice o takim samym typie elementów są zgodne, jeśli jedna ma zakres indeksów 0..99, a druga — 1..100?
  • Nie da się rozróżnić typów o takiej samej strukturze, nawet jeśli w naszym zamierzeniu służą zupełnie różnym celom.
  • Przykład: Odległość wyrażona w dwóch różnych jednostkach, w obydwu przypadkach przechowywana w zmiennych typu float. Zauważmy, że stosowanie zgodności nazwy pozwala ten problem rozwiązać przez „rozdzielenie” typu float na dwa nowe typy (zob. przykład poniżej).
  • Problem ten można rozwiązać za pomocą dwóch osobnych mechanizmów: podtypów i typów pochodnych. Tak jest w Adzie.
  • Zatem zdefiniowanie zgodności typu przez zgodność struktury jest znacznie bardziej elastyczne, ale trudniejsze w implementacji.

Typy pochodne

  • Typ pochodny to nowy typ oparty na już istniejącym.
  • Dziedziczy wszystkie własności typu bazowego.
  • Zakłada się, że nie jest zgodny z typem bazowym.
  • To pozwala konstruować typy, które są identyczne co do struktury, ale niezgodne.
  • Przykład:
     type metry is new Float;
     type stopy is new Float;

Podtypy

  • Podtyp to typ już istniejący z pewnym ograniczeniem zakresu.
  • Zakłada się, że jest zgodny z typem bazowym.
  • Przykład:
     subtype SmallInt is Integer range 0..99;

Kwestie praktyczne

  • Języki programowania zwykle stosują mieszany sposób sprawdzania zgodności typów.
  • Zasady zgodności typów są bardzo istotne, gdy język jest oszczędny w aplikowaniu niejawnych konwersji.
  • Rozważmy deklarację w Adzie:
     A, B: array (1..100) of Integer;
  • W tym przypadku Ada tworzy dwa „anonimowe” typy i nadaje je tablicom A i B. W rezultacie tablice te mają niezgodne typy.
  • Możemy jednak użyć deklaracji, która zapewni zgodność typu tych tablic:
     type Tab100 is array (1..100) of Integer;
     A, B: Tab100;

Zakres widoczności

Zakres widoczności zmiennej to zbiór tych instrukcji programu, w których zmienna ta jest widoczna, tzn. można się do niej odwołać. Mówimy, że zmienna jest lokalna (w jednostce programu lub w bloku), jeśli jest zadeklarowana w tej jednostce. Mówimy, że zmienna jest nielokalna, jeśli jest widoczna, ale zadeklarowana gdzie indziej. Zasady rozstrzygania zakresu w danym języku mówią, w jaki sposób odwołania do nazw są wiązane ze zmiennymi.

Zakres statyczny

Zakres statyczny opiera się na kodzie programu (w sensie tekstowym). Jest powszechnie stosowany w językach wywodzących się z Algolu. Spotykamy dwie kategorie języków z zakresem statycznym: dopuszczające zagnieżdżanie podprogramów (np. Ada, Pascal) i nie dopuszczające takiego zagnieżdżania (np. C). W tym drugim przypadku zagnieżdżanie zakresów może jednak nastąpić poprzez zagnieżdżenie definicji klas. W dalszych rozważaniach zakładamy, że wszystkie zakresy są związane z jednostkami programu i że rozstrzyganie zakresu jest jedyną metodą odwołania się do zmiennych nielokalnych. Powyższe założenie nie we wszystkich językach jest prawdziwe (np. operator :: w języku C++ pozwala na dostęp do „niewodocznej” zmiennej). Rozważamy teraz parę kwestii związanych ze statycznym zakresem widoczności.

Odwoływanie się do zmiennych w językach z zakresem statycznym

  • Napotkawszy odwołanie do zmiennej, kompilator musi odnaleźć jej deklarację i określić jej atrybuty.
  • Deklaracji szuka się najpierw w bieżącej jednostce programu.
  • Jeśli tu jej nie ma, trzeba szukać w jednostce „okalającej”, zwanej poprzednikiem statycznym.
  • Jeśli i tu jej nie ma, trzeba szukać w poprzedniku poprzednika itd. (czyli w przodkach statycznych), być może docierając aż do zakresu globalnego.
  • Zmienne mogą się przesłaniać. Jeśli w bliższym przodku statycznym jest zadeklarowana zmienna o takiej samej nazwie, jak w dalszym przodku, to przesłania ona tę dalszą.

Dostęp do przesłoniętych zmiennych

  • Język może oferować mechanizmy pozwalające na dostęp do przesłoniętych zmiennych.
  • W języku C++ jest to operator ::; do przesłoniętej zmiennej można się odwołać, pisząc nazwa_klasy::nazwa_zmiennej.
  • W Adzie podobną rolę spełnia kropka, np. nazwa_jednostki.nazwa_zmiennej.

Bloki

  • Niektóre języki pozwalają na deklarowanie zmiennych w blokach wewnątrz jednostek programu.
  • Przykładowo, w języku C i jego pochodnych można zadeklarować zmienną na początku dowolnego bloku wyznaczonego przez nawiasy klamrowe.
  • Podobnie można zrobić w Adzie, używając konstrukcji declare przed blokiem wyznaczonym przez begin ... end.
  • W Javie, C++ i C# można zadeklarować zmienną w instrukcji pętli for.
  • We wszystkich tych przypadkach zakresem widoczności zmiennej jest rozważany blok („od klamry do klamry”).
  • Tak zadeklarowane zmienne są dynamiczne na stosie. Pamięć jest alokowana przy wejściu do bloku, a dealokowana przy wyjściu z niego.

Problemy z zakresem statycznym

  • Zwykle problemy biorą się ze zbyt swobodnego dostępu do zmiennych lub podprogramów.
  • Wszystkie zmienne w głównym programie (tzn. w zakresie globalnym) są widoczne dla wszystkich procedur (podprogramów).
  • Nie ma sposobu, by narzucić szczegółowe ograniczenia w dostępie do podprogramów.
  • To często prowadzi do nadużywania globalnych zmiennych i procedur.

Przykład

  • Rozważmy program z następującymi zakresami; przyjmijmy, że są to zagnieżdżone definicje procedur.
     P0 {
       P1 {
         P3 {
         }
         P4 {
         }
       }
       P2 {
         P5 {
         }
       }
     }
  • Zakres P0 to zakres globalny, P1 i P2 są zadeklarowane wewnątrz P0 itp.
  • W typowym układzie wywołania mogłyby być ułożone tak: P0 wywołuje P1 i P2; P1 wywołuje P3 i P4; P2 wywołuje P1 i P5.
  • Co zrobić, jeśli się okaże, że P4 potrzebuje odwołać się do danych w zakresie P2?
  • Można przenieść P4 do wnętrza P2, ale wówczas nie będzie możliwe wywołanie P4 przez P1, a P4 straci dostęp do danych w P1.
  • Można też „uwspólnić” dane potrzebne dla P4, przenosząc je z P2 do P0.
  • Tego rodzaju problemy występują i dla danych, i dla procedur.
  • Stąd bierze się nadużywanie bytów globalnych.

Zakres dynamiczny

Zakres dynamiczny opiera się na kolejności wywołań podprogramów, a nie na ich rozmieszczeniu „przestrzennym”. Jako kryterium rozstrzygania o dostępie przyjmujemy więc bliskość czasową, a nie przestrzenną. Tym razem zastanówmy się więc nad problemami dynamicznego zakresu widoczności.

Odwoływanie się do zmiennych w językach z zakresem dynamicznym

  • Napotkawszy odwołanie do zmiennej, kompilator musi odnaleźć jej deklarację i określić jej atrybuty.
  • Deklaracji szuka się najpierw w bieżącym podprogramie (podobnie jak dla zakresu statycznego).
  • Jeśli tu jej nie ma, trzeba szukać w podprogramie, który wywołał bieżący podprogram, zwanym poprzednikiem dynamicznym.
  • Jeśli i tu jej nie ma, trzeba szukać w poprzedniku poprzednika itd. (czyli w przodkach dynamicznych), być może docierając aż do zakresu globalnego, czyli do programu głównego.
  • Zmienne mogą się przesłaniać. Jeśli w bliższym przodku dynamicznym jest zadeklarowana zmienna o takiej samej nazwie, jak w dalszym przodku, to przesłania ona tę dalszą.

Przykład

  • Rozważmy program z następującymi deklaracjami:
     P0() {
       int x;
       
       P1() {
         int x;
         P2();
       }
       
       P2() {
         Put(x);
       }
     
       P1();
     }
  • W języku, który stosuje zakresy dynamiczne, odwołanie do zmiennej x w wywołaniu Put(x) odnosi się do zmiennej zadeklarowanej w P1, gdyż P1 jest poprzednikiem dynamicznym dla P2.
  • Gdyby język stosował zakresy statyczne, to to samo odwołanie odnosiłoby się do zmiennej zadeklarowanej w P0.

Problemy z zakresem dynamicznym

  • Zaletą jest w wielu sytuacjach wygoda programisty oraz prosta implementacja.
  • Wad jest niestety więcej...
  • Gorsza efektywność, gdyż rozstrzyganie zakresu musi być robione dynamicznie.
  • Nie da się statycznie sprawdzić zgodności typów dla zmiennych nielokalnych.
  • Słaba czytelność odwołań.
  • Podprogramy są wykonywane w środowisku wcześniej wywołanych podprogramów, które jeszcze nie zakończyły działania.
  • Stąd pomiędzy rozpoczęciem a zakończeniem działania podprogramu jego lokalne zmienne są widoczne dla innych podprogramów, niezależnie od ich bliskości przestrzennej.
  • A zatem ponownie problemem jest niekiedy zbyt swobodny dostęp.

Zakres widoczności a okres życia

Zakres widoczności i okres życia to dwa różne pojęcia. Zakres widoczności (zwłaszcza statyczny) to pojęcie związane z rozmieszczeniem kodu, czyli przestrzenią. Okres życia to pojęcie związane z czasem. W wielu typowych sytuacjach jest między nimi związek.

Przykładowo, rozważmy zmienną zadeklarowaną w metodzie, która nie wywołuje innych metod. Jej zakres widoczności rozciąga się od deklaracji do końca metody. Jej okres życia zaczyna się przy wejściu do metody i kończy się w chwili, gdy kończy się wykonanie metody. W tym przypadku obydwa pojęcia są bardzo bliskie.

Często jest jednak zupełnie inaczej. Zmienna zadeklarowana w języku C jako static wewnątrz funkcji ma lokalny zakres widoczności (tę funkcję, w której jest zadeklarowana), ale globalny okres życia (tzn. cały czas wykonania programu). Gdy wewnątrz podprogramu następuje wywołanie innego podprogramu, zmienne lokalne tego pierwszego przestają być widoczne na czas wywołania. Ich „życie” trwa jednak nadal i są ponownie widoczne po powrocie z podprogramu.

Inne problemy dotyczące zmiennych

Oto kilka problemów związanych ze zmiennymi, których nie mieliśmy dotychczas sposobności omówić.

Środowisko odwołań

  • Zbiór wszystkich nazw widocznych w danym punkcie programu nazywamy środowiskiem odwołań tego punktu.
  • W języku z zakresami statycznymi środowisko tworzą wszystkie zmienne zadeklarowane lokalnie wraz ze zmiennymi z przodków statycznych, z wyjątkiem zmiennych przesłoniętych.
  • Mówimy, że podprogram jest aktywny, jeśli jego wykonanie się rozpoczęło i jeszcze nie zakończyło.
  • W języku z zakresami dynamicznymi środowisko tworzą wszystkie zmienne zadeklarowane lokalnie wraz ze zmiennymi z aktywnych podprogramów, z wyjątkiem zmiennych przesłoniętych.

Stałe nazwane

  • Stała nazwana to zmienna, która jest wiązana z wartością tylko raz — w chwili wiązania z pamięcią.
  • Przykład: Można by używać stałej o nazwie pi zamiast 3.1415926...
  • To w oczywisty sposób polepsza czytelność i ułatwia modyfikowanie programu.
  • Niektóre języki stosują statyczne wiązanie wartości dla stałych nazwanych. W takim przypadku do definiowania stałych można używać jedynie wyrażeń stałych.
  • Inne języki pozwalają na wiązanie dynamiczne. Wówczas do definiowania stałych można użyć zmiennych (w zakresie widoczności). Tak jest np. Adzie, C++ i Javie.

Inicjacja zmiennych

  • Wiązanie zmiennej z wartością w chwili, gdy jest ona wiązana z pamięcią, nazywane jest inicjacją.
  • Rzecz jest podobna, jak w przypadku nazwanej stałej, ale wartość zmiennej może oczywiście się zmieniać.
  • Jeśli wiązanie z pamięcią (alokacja) jest statyczne, to inicjacja także jest statyczna (w czasie kompilacji lub bezpośrednio przed wykonaniem).
  • Jeśli wiązanie z pamięcią (alokacja) jest dynamiczne, to inicjacja także jest dynamiczna.
  • Dla zmiennych statycznych inicjacja następuje zatem tylko raz, natomiast dla dynamicznych — przy każdej alokacji.
  • W wielu językach inicjacji można dokonać przy deklaracji zmiennej, np. int i = 2;