Metody realizacji języków programowania/MRJP Wykład 3: Różnice pomiędzy wersjami

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania
Mbiskup (dyskusja | edycje)
Mbiskup (dyskusja | edycje)
 
(Nie pokazano 92 wersji utworzonych przez 4 użytkowników)
Linia 1: Linia 1:
Autor: Marek Biskup (mbiskup@mimuw.edu.pl)
= Statyczna analiza semantyczna =
= Statyczna analiza semantyczna =


Statyczna analiza semantyczna ma na celu częściowe sprawdzenie poprawności kodu źródłowego programu w czasie kompilacji. Na tym etapie zakładamy, że program źródłowy
Statyczna analiza semantyczna ma na celu częściowe sprawdzenie poprawności kodu źródłowego programu w czasie kompilacji. Na tym etapie zakładamy, że dla programu źródłowego zostało już zbudowane drzewo składniowe oraz tablice symboli. Założenia te nie zawsze są konieczne. Niektóre kompilatory mogą przeprowadzać analizę semantyczną w czasie analizy syntaktycznej kodu, w czasie budowy tablicy symboli lub w czasie generowania kodu. Jednak nasze podejście pozwala łatwiej zrozumieć tę fazę działania kompilatora.  
został już sparsowany i zostało zbudowane drzewo składniowe. Zakładamy również, że
 
tablice symboli zostały już zbudowane. Założenia te nie zawsze są konieczne. Niektóre
Głównym zadaniem w czasie analizy semantycznej jest sprawdzenie, czy program może być jednoznacznie skompilowany. Pewne konstrukcje, mimo że dopuszczalne przez gramatykę języka, mogą być niepoprawne. Poniżej zaprezentowane jest kilka przykładów niepoprawnych fragmentów kodu, dla których błędy mogą zostać wykryte w czasie analizy semantycznej. Przykłady są podane w języku ''C++'', ale w innych językach występują te same typy błędów.
kompilatory mogą przeprowadzać analizę semantyczną już w czasie parsowania kodu lub
 
w czasie budowy tablicy symboli. Jednak takie podejście pozwala łatwiej zrozumieć tę fazę  
*Użycie zmiennej, która nie była zadeklarowana:
działania kompilatora.  
 
'''int''' f('''void''') {
    '''int''' zmienna = 5;
    '''return''' zmianna;          // literówka!
}
 
* Niezgodność typów przy przypisaniu:
 
'''void''' f('''void''') {
    string s = "tekst";
    '''int''' a = s;              // błąd typu!
}
 
* Wywołanie nieistniejącej metody obiektu lub nieistniejącej funkcji:
 
'''class''' C {
    void print();
};
'''void''' f('''void''') {
    C obiekt;
    obiekt.prnt();          // literówka!
}
 
* Odwołanie do zmiennych klasy z metod statycznych:
 
'''class''' C {
    '''int''' a;
    '''static''' '''int''' getA() {
      '''return''' a;            // błąd! - funkcja getA jest statyczna
    }
};
 
* Użycie instrukcji w niedozwolonych miejscach, np. '''this''' poza klasą (choć ten problem nie występuje na przykład w ''Javie''), '''break''' poza pętlą:
 
'''int''' main('''int''' argc, '''char'''** argv) {
    '''if''' (argc > 2)
      '''break''';                // błąd! - break poza pętlą
    '''else'''
      '''return''' '''this'''->a;      // błąd! - main nie jest metodą klasową
}


Głównym zadaniem w czasie analizy semantycznej jest sprawdzenie, czy program może być
* Przypisanie do nie L-wartości (np. do wyniku zwracanego przez funkcję lub do wyniku dodawania - więcej o L-wartościach będzie napisane w dalszej części tego wykładu):
jednoznacznie skompilowany. Pewne konstrukcje, mimo że dopuszczalne przez gramatykę języka, mogą być niepoprawne. Na przykład:


* użycie zmiennej, która nie była zadeklarowana.
'''int''' f('''void''') {
* niezgodność typów przy przypisaniu.
    '''int a = 2;
* wywołanie nieistniejącej metody obiektu.
    '''int b = 4;
* odwołanie do zmiennych klasy z metod statycznych.
    (a + b) = 8;          // błąd! (a + b) jest tymczasową wartością
* użycie instrukcji w niedozwolonych miejscach, np. '''this''' poza klasą, '''break''' poza pętlą.
                          // i nie odpowiada żadnej lokacji w pamięci
* przypisanie do nie l-wartości (np. do wyniku zwracanego przez funkcję lub do wyniku dodawania - więcej o l-wartościach będzie napisane w FIXME).
}


Zakres analizy semantycznej zależy od języka programowania. Niektóre języki dopuszczają
Zakres analizy semantycznej zależy od języka programowania. Niektóre języki dopuszczają użycie niezadeklarowanych zmiennych. Niektóre sprawdzają poprawność przypisania lub wywołania metody w czasie wykonywania programu, zamiast w czasie kompilacji. Pozostawienie sprawdzania poprawności na czas wykonywania programu można nazwać ''dynamiczną'' analizą semantyczną (lub częściej ''dynamicznym typowaniem'', gdyż sprawdzanie typów jest znaczną częścią analizy semantycznej).
użycie niezadeklarowanych zmiennych, niektóre sprawdzają poprawność przypisania, czy wywołania metody, w czasie wykonywania programu. Pozostawienie sprawdzania poprawności
na czas wykonywania programu można nazwać ''dynamiczną'' analizą semantyczną (lub częściej ''dynamicznym typowaniem'', gdyż sprawdzanie typów jest znaczą częścią analizy semantycznej).


Ogólnie, im więcej jest sprawdzane w tej fazie statyczniej analizy semantycznej tym łatwiej pisać skomplikowane a jednocześnie poprawne programy. Dużą część błędów, które mogłyby powstać w czasie wykonywania programu, wykrywa się na etapie kompilacji. Dzięki temu można znacznie skrócić fazę testów oprogramowania i pisać kod z mniejszą liczbą błędów.
Łatwiej pisać skomplikowane, a jednocześnie poprawne programy jeśli kompilator dokonuje szerszej i głębszej analizy semantycznej kodu źródłowego. Dużą część błędów (np. literówki), które mogłyby powstać w czasie wykonywania programu, wykrywa się na etapie kompilacji. Dzięki temu można znacznie skrócić fazę testów oprogramowania i pisać kod z mniejszą liczbą błędów.


Z drugiej strony zaawansowana statyczna analiza semantyczna wymaga pewnego nakładu ze strony programisty i ogranicza możliwości języków programowania. Sama deklaracja każdej  
Z drugiej strony zaawansowana statyczna analiza semantyczna wymaga pewnego nakładu pracy ze strony programisty używającego tego języka i ogranicza możliwości języków programowania. Sama deklaracja każdej zmiennej zabiera czas programiście piszącemu kod źródłowy. Dlatego możliwość używania niezadeklarowanych zmiennych jest pożądana w językach skryptowych oraz używanych do szybkiego prototypowania. Języki z brakiem statycznego sprawdzania poprawności typów (w szczególności języki obiektowe, jak ''smalltalk'' czy ''python'') są bardziej elastyczne. Pozwalają na skupienie się na samym zadaniu do rozwiązania, a nie na zmieszczeniu rozwiązania w ramach języka programowania. To z kolei ułatwia i przyspiesza projektowanie, zarówno w małych projektach, jak i w dużych. Projektant języka programowania musi odpowiednio wyważyć zalety i wady statycznej analizy semantycznej mając na uwadze przeznaczenie języka.  
zmiennej zabiera czas programiście piszącego kod. Dlatego możliwość używania niezadeklarowanych zmiennych jest pożądana w językach skryptowych oraz używanych do szybkiego prototypowania. Języki z brakiem statycznego sprawdzania poprawności typów (w szczególności języki obiektowe, jak ''smalltalk'' czy ''python'') są bardziej elastyczne.  
Pozwalają na skupienie się na samym zadaniu do rozwiązania, a nie na zmieszczeniu rozwiązania w ramach języka programowania. To z kolei ułatwia i przyspiesza projektowanie, zarówno w małych projektach jak i w dużych systemów informatycznych.
Projektant języka programowania musi odpowiednio wyważyć zalety i wady statycznej
analizy semantycznej.  


W tym rozdziale zostaną opisane najistotniejsze elementy statycznej analizy semantycznej:
W tym rozdziale zostaną opisane najistotniejsze elementy statycznej analizy semantycznej:


* kontrola typów,
* kontrola typów, czyli sprawdzanie poprawności typów w każdym węźle drzewa składni programu (w tym także sprawdzanie, czy identyfikatory zostały zadeklarowane);
* kontrola poprawności instrukcji,
* kontrola poprawności instrukcji, czyli sprawdzenie, czy instrukcje i wyrażenia mają sens w kontekście, w którym zostały użyte,
* kontrola nazw.
* kontrola nazw, czyli sprawdzenie, czy nazwy jednoznacznie identyfikują funkcje, etykiety i inne konstrukcje języka programowania.
 


Ten podział nie oznacza, że analiza semantyczna jest rozbita na trzy kroki. W rzeczywistości instrukcje sprawdzane są po kolei, mając na uwadze wszystkie trzy kryteria. Niektóre sprawdzenia, jak na przykład kontrola, czy jakaś etykieta nie została zadeklarowana dwukrotnie, odnoszą się do programu jako do całości.


= Kontrola typów =
= Kontrola typów =
Linia 43: Linia 77:


* przypisania - typ wartości przypisywanej musi być zgodny z typem elementu do którego przypisujemy,
* przypisania - typ wartości przypisywanej musi być zgodny z typem elementu do którego przypisujemy,
* operacje arytmetyczne - wartości, do których używany jest operator arytmetyczny, muszą zgadać się z rodzajem operatora.
* operacje arytmetyczne - wartości, do których używany jest operator arytmetyczny, muszą zgadać się z rodzajem operatora,
* wywołania funkcji - typy parametrów funkcji przy jej wywyłaniu muszą zgadzać się z typami zadeklarowanymi,
* wywołania funkcji - typy parametrów funkcji przy jej wywołaniu muszą zgadzać się z typami zadeklarowanymi,
* odwołania do pól rekordu - rekord, do którego się odwołujemy, musi mieć pole podanej nazwie,
* odwołania do pól rekordu - rekord, do którego się odwołujemy, musi mieć pole o podanej nazwie,
* wywołania metod obiektu - obiekt musi być klasy, która zawiera metodę, która jest  wywoływana.
* wywołania metod obiektu - obiekt musi być instancją klasy, która zawiera wywoływaną metodę.


Zgodność typów nie oznacza, że typy są identyczne, ale, że operacja może być zastosowana dla tych typów. W dalszej części tego rozdziału przedstawione będą rodzaje typów, ich zgodność oraz kontrola ich poprawności.
Zgodność typów nie oznacza, że typy są identyczne, ale, że operacja może być zastosowana dla tych typów. W dalszej części tego rozdziału przedstawione będą rodzaje typów, ich zgodność oraz kontrola ich poprawności.
W czasie kontroli typów sprawdzane też jest czy identyfikatory były zadeklarowane. Do tego celu służy zbudowana wcześniej tablica symboli.


== System typów ==
== System typów ==


Różne języki programowania mają różne systemy typów. Nie wszystkie będą zawierały wszystkie z omówionych niżej typów, a niektóre będą miały system typów bardziej rozbudowany. Typy obejmują:
Typy obejmują:


* typy arytmetyczne  
* typy arytmetyczne ('''int''', '''char''', etc.),
* wskaźniki i referencje
* wskaźniki i referencje,
* rekordy
* rekordy,
* obiekty
* obiekty,
* tablice
* tablice,
* funkcje
* funkcje.
 
Różne języki programowania mają różne systemy typów. Nie wszystkie będą zawierały wszystkie z tych typów, a niektóre będą miały system typów bardziej rozbudowany.


===Typy arytmetyczne===
===Typy arytmetyczne===


Typy arytmetyczne są przeznaczone do bezpośrednich operacji przez procesor przez procesor lub maszynę wirtualną. Obejmują one liczby całkowite (1, 2, 4, 8 bajtów), zmiennopozycyjne (4, 8, 10 bajtów) i znaki (1 lub 2 bajty). Nie wszystkie języki programowania dopuszczają typy o wszystkich rozmiarach, a niektóre mogą dopuszczać nawet większe rozmiary. Procesor lub maszyna wirtulana posiada zwykle instrukcje do odczytywania tych wartości z pamięci, wykonywania na nich operacji (dodawanie, mnożenie, etc.) i zapisywanie w pamięci. Czasem jednak nie musi być to prawdą. Na przykład przy kompilacji na architektury bez możliwości obliczeń zmiennopozycyjnych dodawanie dwóch liczb zmiennopozycyjnych może być zamienione na wywołanie odpowiedniej metody.
Typy arytmetyczne są przeznaczone do bezpośredniej manipulacji przez procesor lub maszynę wirtualną. Obejmują one liczby całkowite (1, 2, 4 lub 8 bajtów), zmiennopozycyjne (4, 8 lub 10 bajtów) i znaki (1 lub 2 bajty). Nie wszystkie języki programowania dopuszczają typy o wszystkich rozmiarach, a niektóre mogą oferować bogatszy zestaw typów arytmetycznych. Procesor lub maszyna wirtualna posiada zwykle instrukcję do odczytywania tych wartości z pamięci, wykonywania na nich operacji (dodawanie, mnożenie, etc.) i zapisywanie w pamięci. Czasem jednak, na przykład dla architektur bez możliwości obliczeń zmiennopozycyjnych, operacje mogą być zamieniane na wywołania odpowiednich funkcji lub przerwań.


===Wskaźniki i referencje===
===Wskaźniki i referencje===


Referencje to małe obiekty, które przechowują informację o dostępie do innej wartości (np. wartości zmiennej lub obiekt) - wskazują na nią. Dzięki referencji można mieć dostęp  do wskazywanej wartości. Zwykle wystarczającą informacją o wskazywanej wartości jest jej adres w pamięci.
Referencje to małe obiekty, które przechowują informację o dostępie do innej wartości (np. do wartości zmiennej lub do obiektu) - wskazują na nią. Dzięki referencji można mieć dostęp  do wskazywanej wartości. Zwykle wystarczającą informacją jest adres w pamięci.


Wskaźniki są tym samym co referencje, ale tym razem zakłada się, że przechowują wyłącznie adres pamięci pod którym zapisana jest inna wartość. Niech, na przykład, pod adresem '''0x11111111''' będzie zapisana zmienna '''x'''. Niech '''x''' będzie równe 2. Jeśli zmienna '''w''' jest wskaźnikiem na '''x''' to jej wartość będzie równa '''0x11111111'''. Mając dostęp do zmiennej '''w''' możemy odczytać wartość zmiennej '''x'''. Jeśli teraz dokonamy przypisania:
Wskaźniki są tym samym, co referencje, ale tym razem zakłada się, że przechowują wyłącznie adres pamięci, pod którym zapisana jest inna wartość.
 
Używając referencji lub wskaźnika niejako tworzymy inną nazwę na istniejącą już wartość. Niech, na przykład, pod adresem '''0x11111111''' będzie zapisana zmienna '''x''', która jest równa 2. Jeśli zmienna '''w''' jest wskaźnikiem na '''x''', to wartość '''w''' będzie równa '''0x11111111'''. Mając dostęp do zmiennej '''w''' możemy odczytać wartość zmiennej '''x'''. Jeśli teraz dokonamy przypisania:


  x = 3;
  x = 3;
Linia 75: Linia 115:
to korzystając z tej samej zmiennej '''w''' możemy odczytać nową wartość równą 3.
to korzystając z tej samej zmiennej '''w''' możemy odczytać nową wartość równą 3.


Wskaźniki (i referencje) mogą mieć różny typ: wskaźnik na liczbę całkowitą na liczbę zmiennopozycyjną, na obiekt, element tablicy.
Wskaźniki (i referencje) mogą mieć różny typ: wskaźnik na liczbę całkowitą, na liczbę zmiennopozycyjną, na obiekt, element tablicy.


Referencje i wskaźniki to różne nazwy na tą samą rzecz. Jedyna różnica jest taka, że mówiąc wskaźnik mamy na myśli tylko adres w pamięci, a mówiąc referencja, być może również jakiś inny rodzaj dostępu.
Referencje i wskaźniki w rzeczywistości są tym samym. Jedyna różnica jest taka, że mówiąc wskaźnik mamy na myśli tylko adres w pamięci, a mówiąc referencja, być może również jakiś inny rodzaj dostępu (np. w systemach rozproszonych mówi się o referencji do zdalnego obiektu).


Niektóre języki programowania, na przykład '''C++''' rozróżniają między wskaźnikami a referencjami i przypisują im nieco różną składnię i semantykę. Na przykład w '''C++''' referencje są niezmienialne, tzn. nie można zmienić adresu na który wzkazuje zainicjalizowana referencja. W innych, jak Java jest tylko jeden rodzaj i nazywa się je referencjami (słowo ''wskaźnik'' nie jest używane wśród programistów Javy).
Niektóre języki programowania, na przykład ''C++'' rozróżniają między wskaźnikami a referencjami i przypisują im nieco różną składnię i semantykę. Na przykład w ''C++'' referencje są niezmienialne, tzn. nie można zmienić adresu na który wskazuje zainicjalizowana referencja. W innych, jak ''Java'' jest tylko jeden rodzaj i nazywa się je referencjami (słowo ''wskaźnik'' nie jest używane wśród programistów ''Javy'').


===Rekordy===
===Rekordy===


Rekordy (zwane także strukturami lub krotkami) to złożone typy, które zawierają pola o innych typach. Na przykład konstrukcja z języka C:
Rekordy (zwane także strukturami lub krotkami) to złożone typy, które zawierają pola o innych typach. Na przykład konstrukcja z języka ''C'':


  struct A {
  '''struct''' A {
     int I;
     '''int''' I;
     double D;
     '''double''' D;
  };
  };


definiuje typ rekordowy A, który zawiera dwa pola: liczbę całkowitą I oraz liczbę zmiennopozycyjną D.
definiuje typ rekordowy '''A''', który zawiera dwa pola: liczbę całkowitą '''I''' oraz liczbę zmiennopozycyjną '''D'''.


Rekord grupuje wartości różnych typów i pozwala na przechowywanie tych wartości w pojedynczej zmiennej. Odwołanie do poszczególnych pól rekordu jest wykonywane przez podanie nazwy pola (czasem numeru pola), np. '''a.I''' w języku '''C''' oznacza wzięcie pola I ze zmiennej '''a''', która jest typu rekordowego.
Rekord grupuje wartości różnych typów i pozwala na przechowywanie tych wartości w pojedynczej zmiennej. Odwołanie do poszczególnych pól rekordu jest wykonywane przez podanie nazwy pola (czasem numeru pola), np. '''a.I''' w języku '''C''' oznacza wzięcie pola '''I''' ze zmiennej '''a''', która jest typu rekordowego. Operator '''.''' (kropka) oznacza dostęp do pola.


Rekordy mogą zawierać pola o dowolnych typach. Szczegóły są uzależnione od języka programowania.
Rekordy mogą zawierać pola o dowolnych typach. Szczegóły są uzależnione od języka programowania.
Linia 98: Linia 138:
===Obiekty===
===Obiekty===


Obiekty są jak rekordy, ale mogą przechowywać funkcje (zwane metodami). Z wnątrza takiej funkcji jest dostęp do pól obiektu, z którego funkcja została wywołana. Główną techą obiektów jest to, że ich typy - klasy - mogą dziedziczyć po innych klasach.
Obiekty są jak rekordy, ale mogą przechowywać funkcje (zwane metodami). Z wnętrza takiej funkcji jest dostęp do pól obiektu, z którego funkcja została wywołana. Główną cechą obiektów jest to, że ich typy - klasy - mogą dziedziczyć po innych klasach. Zainteresowanego czytelnika odsyłamy do książek poświęconych podstawom programowania obiektowego.


===Tablice===
===Tablice===


Tablice to ciągły obszar pamięci zawierający kolejne instancje typu tablicy. Na przykład tablica 100 liczb całkowitych to obszar pamięci zawierający kolejno 100 liczb całkowitych.
Tablice to ciągły obszar pamięci zawierający kolejne instancje typu tablicy. Na przykład tablica 100 liczb całkowitych to obszar pamięci zawierający kolejno 100 liczb całkowitych.
Mogą być tablice obiektów, referencji, tablic.
Mogą być tablice obiektów, referencji, tablic, etc.


===Funkcje===
===Funkcje===


Niekóre języki dopuszczają typy funkcyjne, czyli przypisywanie funkcji do zmiennej.
W przypadku funkcji też możemy mówić o typach. Typy funkcji są takie same, jeśli funkcja przyjmuje tę samą liczbę argumentów, argumenty są tego samego typu oraz typ wartości zwracanej test taki sam. Typ funkcji można zapisać w postaci sygnatury typu funkcji:


== Sprawdzanie poprawności typów ==
Typ_Arg1, Typ_Arg2, ..., Typ_ArgN -> Typ_Wart
 
Liczba '''N''' oznacza liczbę argumentów, <tt>Typ_Arg'''i'''</tt> to typ i-tego argumentu, a <tt>Typ_Wart</tt> to typ wartości. Jeśli na przykład w języku '''C''' mamy funkcję:
 
'''double''' funkcja('''char''' a, '''int'''* p);
 
to jej typem jest:


Sprawdzanie poprawności typów polega na przejściu drzewa składniowego, sprawdzeniu w każdym węźle, czy jego operacja może być zastosowana do typów dzieci tego węzła i w końcu określeniu typu samego węzła. Przy przechodzeniu drzewa korzysta się z tablicy symboli do określenia typów zmiennych, funkcji, pól rekordów, etc.
'''char''', '''int'''* -> '''double'''


Aby lepiej zrozumieć analizę semantyczną spójrzmy na prosty przykład. Chcemy sprawdzić fragment drzewa odpowiadający instrukcji
Jak widać, aby sprawdzić, czy dwie funkcje są tego samego typu, wystarczy  porównać ich sygnatury.


x = y + 5;
W przypadku języków proceduralnych lub obiektowych (ogólnie - w językach imperatywnych) rzadko używa się zmiennych, których typem jest funkcja (taka zmienna wtedy zwykle przechowuje adres, pod którym jest kod funkcji). Jednak w językach funkcyjnych zmienne bardzo często przechowują funkcje. Funkcje są w tych językach pełnoprawnymi wartościami i można na nich przeprowadzać operacje, które mogą zmienić ich typ (na przykład mając funkcję biorącą liczbę całkowitą i znak oraz dającą liczbę zmiennopozycyjną można ''zaaplikować'' jeden z argumentów, np. liczbę całkowitą; wynikiem aplikacji będzie funkcja, która oczekuje znaku i daje liczbę zmiennopozycyjną). Dlatego w językach funkcyjnych bardzo ważna jest analiza typów funkcji. Więcej o językach funkcyjnych jest napisane w [[Metody realizacji języków programowania/MRJP Wykład 12|rozdziale 12]].


W tablicy zaczynamy od liści drzewa składniowego. Dla zmiennych '''x''' i '''y''' typy sprawdzamy w tablicy symboli. Jeśli '''x''' lub '''y''' nie zostało zadeklarowane to zgłaszamy błąd niezadeklarowanej zmiennej. Przypuśćmy, że typem '''x''' jest '''double''', czyli liczba zmiennoprzecinkowa podwójnej precyzji, a typem '''y''' jest '''int''', czyli liczba całkowita. Typem węzła odpowiadającego stałej 5 jest również '''int'''. Sprawdzamy węzeł '+': typami dzieci tego węzła są '''int''' oraz '''int''', więc operacja dodawania może być przeprowadzona. Wynikiem jest '''int'''. Sprawdzamy węzeł '=' (przypisanie): typem lewej strony jest '''double''', a prawej '''int'''. Zakładamy, że nasz kompilator pozwala na przypisanie zmiennej całkowitej do zmiennoprzecinkowej więc całą instrukcję uznajemy za poprawną. Jako typ całości bierzemy '''double''', czyli typ zmiennej na którą przypisujemy (jest to semantyka wzięta z języka '''C''', który dopuszcza przypisania w postaci x = y = 1; alternatywnie moglibyśmy takich  przypisań zabronić i jako typ wyniku wziąć '''void''' - specjalny typ, który nie niesie żadnej wartości).
==Konwersja typów==


Na powyższym przykładzie widzimy, że analiza semantyczna jest dość skomplikowana, ale można rozbić na małe fragmenty - każdy odpowiada pojedynczemu węzłowi w drzewi składniowym.
Wartości niektórych typów mogą w pewnych sytuacjach być konwertowane do wartości innego typu. Na przykład, jeśli oczekujemy liczby całkowitej 4-bajtowej, a mamy 2 bajtową, to możemy rozszerzyć o brakujące dwa bajty nie gubiąc przy tym żadnej informacji.  


W podpunktach niżej omówione zostaną reguły odpowiadające pewnym węzłom w drzewie składniowym.
Taka niejawna konwersja typów zależy od kompilatora. Często obejmuje ona:


===Przypisania===
* konwersję liczb całkowitych do zmiennoprzecinkowych
* konwersja liczb całkowitych do liczb całkowitych o większej precyzji
* konwersja liczb zmiennoprzecinkowych do zmiennoprzecinkowych o większej precyzji
* konwersja typu obiektu do nadklasy (gdyż podklasa może być użyta wszędzie tak, gdzie oczekujemy nadklasy


Przy przypisaniach typ wartości, którą przypisujemy musi być zgodny z typem elementu do kórego przypisujemy. Zgodność w tym przypadku może być ścisła - ten sam typ, lub może być dozwolona niejawna konwersja typów. Konwersja pozwala na przypisanie na przykład wartości całkowitej na liczbę zmiennopozycyjną, wartości całkowitej na ciąg znaków (dostaniemy liczbę zapisaną w kodzie ASCII) lub obiektu na zmienną o typie będącym nadklasą typu obiektu (w językach obiektowych wszędzie, gdzie można użyć pewnej klasy, można użyć też jej podklasy, więc takie przypisanie ma sens
Dobrze zaprojektowany język powinien zabraniać niejawnej konwersji, przy której może wystąpić utrata informacji. Z kolei niejawna konwersja, przy której nie ma utraty informacji, znacząco ułatwia pracę programiście.


===Operacje arytmetyczne===
Przy sprawdzaniu typów, przed zgłoszeniem błędu niezgodności typów, kompilator powinien zawsze sprawdzić, czy podany typ może być poddany konwersji niejawnej. Może również zajść sytuacja, w której konwersja jest możliwa na dwa różne sposoby. Wtedy semantyka dobrze zaprojektowanego języka powinna określać priorytety konwersji typów lub określać, że taka niejednoznaczność jest błędem.


Oprócz niejawnej konwersji typów, języki programowania często pozwalają programiście na jawną zmianę typu elementu. W przypadku konwersji zmiennej rzeczywistej na całkowitą oznacza to utratę precyzji (ale zaakceptowaną przez programistę, gdyż jawnie zaznaczył ją w programie). W przypadku konwersji typów obiektów (zwanej rzutowaniem) może to prowadzić do błędów, gdy konwertujemy typ obiektu na klasę całkowicie nie związaną z poprzednią klasą. Takie błędy nie są możliwe do wykrycia na etapie kompilacji. Projektant języka może dać możliwość sprawdzenia takiego rzutowania w czasie wykonania programu. Takie podejście zostało zastosowane w języku ''Java''. Pomaga to w wykryciu błędów, ale jednocześnie spowalnia nieco wykonanie programu. Z kolei w ''C++'' poprawność rzutowania nie jest domyślnie sprawdzana. Dzięki temu kod jest szybszy, ale istnieje ryzyko pojawienia się trudnych do lokalizacji błędów (w ''C++'' da się jednak zaznaczyć, czy chcemy dynamicznie sprawdzać poprawność rzutowania obiektów).


W operacjach arytmentycznych wartości, do których używany jest operator arytmetyczny, muszą pasować do rodzaju operatora. Na przykład pomnożenie dwóch liczb całkowitych jest dozwolone, a pomnożenie dwóch stałych napisowych nie. Dopuszczalna jest konwersja taka, jak opisana przy przypisaniach.
== Sprawdzanie poprawności typów ==


===Wywołania funkcji===
Sprawdzanie poprawności typów polega na przejściu drzewa składniowego, sprawdzeniu w każdym węźle, czy jego operacja może być zastosowana do typów dzieci tego węzła i w końcu na określeniu typu samego węzła. Przy przechodzeniu drzewa korzysta się z tablicy symboli do określenia typów zmiennych, funkcji, pól rekordów, etc.


Przy wywołaniach funkcji typy parametrów muszą zgadzać się z typami określonymi w deklaracji funkcji. Dopuszczalna jest konwersja taka, jak opisana przy przypisaniach
Aby lepiej zrozumieć analizę semantyczną spójrzmy na prosty przykład. Chcemy sprawdzić fragment drzewa odpowiadający instrukcji
Jako wynik wywołania funkcji bierze się typ zwracany przez funkcję.


x = y + 5;


===Odwołania do pól rekordu===
W tablicy zaczynamy od liści drzewa składniowego. Dla zmiennych '''x''' i '''y''' typy sprawdzamy w tablicy symboli. Jeśli '''x''' lub '''y''' nie zostało zadeklarowane to zgłaszamy błąd niezadeklarowanej zmiennej. Przypuśćmy, że typem '''x''' jest '''double''', czyli liczba zmiennoprzecinkowa podwójnej precyzji, a typem '''y''' jest '''int''', czyli liczba całkowita. Typem węzła odpowiadającego stałej 5 jest również '''int'''. Sprawdzamy węzeł '+': typami dzieci tego węzła są '''int''' oraz '''int''', więc operacja dodawania może być przeprowadzona. Wynikiem jest typ '''int'''. Sprawdzamy węzeł '=' (przypisanie): typem lewej strony jest '''double''', a prawej '''int'''. Zakładamy, że nasz kompilator pozwala na przypisanie zmiennej całkowitej do zmiennoprzecinkowej więc całą instrukcję uznajemy za poprawną. Jako typ całości bierzemy '''double''', czyli typ zmiennej na którą przypisujemy (jest to semantyka wzięta z języka ''C'', który dopuszcza przypisania w postaci x = y = 1; alternatywnie moglibyśmy takich  przypisań zabronić i jako typ wyniku wziąć '''void''' - specjalny typ, który nie niesie żadnej wartości).


Przy odwołaniach do pól rekordu należy przede wszystkim sprawdzić, czy element, do którego pola się odwołujemy jest na prawdę rekordem (jeśli jest on np. liczbą całkowitą to takie odwołanie jest błędne) oraz, czy taki rekord rzeczywiście zawiera pole o tej nazwie. Nazwy pól rekordów są dostępne w tablicy symboli.
<applet align="left" code="PSViewer" archive="images/d/da/Mrjp3.jar" width="600" height="550">
<param name="cache_archive" value="Mrjp.jar">
<param name="DIR" value="images/1-2/">
</applet>


Na powyższym przykładzie widzimy, że analiza semantyczna jest dość skomplikowana, ale można ją rozbić na małe fragmenty - każdy odpowiada pojedynczemu węzłowi w drzewie składniowym.


===Wywołania metod obiektu===
W podpunktach niżej omówione zostaną reguły odpowiadające pewnym węzłom w drzewie składniowym.


Analiza jest taka jak w przypadku odwołania do pól rekordu oraz wywołaniu funkcji.
===Przypisania===


== Równość typów ==
Przy przypisaniach typ wartości, którą przypisujemy, musi być zgodny z typem elementu, do którego przypisujemy. Zgodność w tym przypadku może być ścisła - ten sam typ, lub może być dozwolona niejawna konwersja typów. Konwersja może pozwolić na przypisanie na przykład wartości całkowitej na liczbę zmiennopozycyjną, wartości całkowitej na ciąg znaków (dostaniemy liczbę zapisaną w kodzie ASCII).


Przy sprawdzaniu poprawności typów trzeba sprawdzić, czy typ wyrażenia jest taki jak oczekujemy (np., taki sam jak typ parametru funkcji, która jest wołana na wyrażeniu).
W językach programowania obiektowego przyjmuje się, że do zmiennej zadeklarowanej jako zmienna typu <tt>T</tt> można przypisywać obiekty podklas klasy <tt>T</tt>. Kontrola poprawności przypisań musi uwzględniać możliwość takiego przypisania.
Trzeba więc porównać typy. W przypadku typów arytmetycznych wystarczy sprawdzić, czy nazwa typu jest taka sama. W przypadku typów rekordowych są dwie możliwości:


* strukturalna
===Operacje arytmetyczne===
* przez nazwę


Równoważność strukturalna sprowadza się do porównania, czy rekordy mają takie same pola. Równoważność przez nazwę występuje gdy możemy robić aliasy dla typów. Takie aliasy, przy równoważności przez nazwę, będą oznaczały różne typy.
W operacjach arytmetycznych wartości, do których używany jest operator arytmetyczny, muszą pasować do rodzaju operatora. Na przykład pomnożenie dwóch liczb całkowitych jest dozwolone, a pomnożenie dwóch stałych napisowych nie.  
Aby zbadać równoważność strukturalną trzeba rekurencyjnie sprawdzić wszystkie pola (kolejność deklaracji pól też jest ważna). Rekurencyjnie, bo rekord może mieć pola także typu rekordowego.


Niektóre języki dopuszczają definiowanie własnej semantyki operatorów arytmetycznych. W takim przypadku programista zwykle pisze kod funkcji, która będzie wywołana zamiast przeprowadzania operacji arytmetycznej. W takim przypadku sprawdzanie semantyki przebiega tak jak opisane niżej sprawdzanie semantyki wywołania funkcji.


== konwersja typów ==
===Wywołania funkcji===


=== typy arytmetyczne i proste (int->double, etc) ===
Przy wywołaniach funkcji typy parametrów muszą zgadzać się z typami określonymi w deklaracji funkcji. Jako wynik wywołania funkcji bierze się typ zwracany przez funkcję.


Przy operacjach arytmetycznych często występuje niejawna konwersja typów. Np. można dodać int i double i wynikiem będzie double (bo ma większą precyzję). Można też wywołać funkcję ,która oczekuje double z parametrem int. Kompilator (z niektórych językach) powienien uznać taką operację za poprawną i dokonać konwersji. Generalnie przyjmuje się zasadę, żę dozwolona jest konwersja z typu o mniejszej dokładności do typu o większej dokładności (uwaga: może być dozwolona konwerja z int do float, chociaż dokładności obu typów nie da się porównać). Przy konwersji w przeciwną stronę kompilator powienien wypisać ostrzeżenie lub tego zabronić.
Ze sprawdzaniem semantyki wywołania funkcji związane jest także sprawdzanie semantyki powrotu z funkcji (choć jest to sprawdzane w innym miejscu kodu źródłowego - przy analizie semantycznej kodu funkcji). Przy analizie semantycznej powrotu z funkcji (zwykle jest to instrukcja <tt>'''return'''</tt>) należy sprawdzić, czy typ wartości zwracanej przez funkcję (zwykle jest to wyrażenie policzone wewnątrz funkcji, np w instrukcji <tt>'''return''' &lt;''Expr''&gt;</tt>, gdzie &lt;''Expr''&gt; oznacza wyrażenie) jest zgodny z zadeklarowanym typem wyniku funkcji.


===Odwołania do pól rekordu===


=== obiekty polimorficzne - rzutowanie ===
Przy odwołaniach do pól rekordu należy przede wszystkim sprawdzić, czy element, do którego pola się odwołujemy jest naprawdę rekordem (jeśli jest on np. liczbą całkowitą to takie odwołanie jest błędne) oraz, czy taki rekord rzeczywiście zawiera pole o tej nazwie. Nazwy pól rekordów są dostępne w tablicy symboli. Typem zapisanym w węźle drzewa składniowego będzie typ pola rekordu.


W przypadku języków obiektowych wszędzie tam, gdzie oczekuje się obiektu pewnej klasy można użyć jej podklasy. Konwersja w drugą stronę nie jest dozwolona
===Wywołania metod obiektu===


=== jawne rzutowanie ===
Analiza jest taka sama, jak w przypadku odwołania do pól rekordu i wywołaniu funkcji.


Często trzeba wykonać jawne rzutowanie. Wtedy można przypisać liczbę zmiennopozycyjną do całkowitej, referencję do referencji podklasy (takie przypisanie może być sprawdzane jedynie dynamicznie). Często języki umożliwiają rzutowanie dowolnego typu na dowolny.
== Równość typów ==


=== Przeciążanie funkcji ===
Przy sprawdzaniu poprawności typów trzeba sprawdzić, czy typ węzła jest taki jak oczekujemy (np. taki sam jak typ parametru funkcji, która jest wołana na wyrażeniu). Trzeba więc porównać typy. W przypadku typów arytmetycznych wystarczy sprawdzić, czy nazwa typu jest taka sama (i oczywiście uwzględnić automatyczną konwersję typów). W przypadku typów rekordowych są dwie możliwości:


Funkcje o tej samej nazwie lecz różnych typach parametrów to funkcje przeciążone. Przykład: operator + działający na intach lub double lub float lub char, .... Przeciążać czasem można też typ wartości zwracanej z funkcji.
* równoważność strukturalna
* równoważność przez nazwę


W przypadku przeciążania typów parametrów wystarczy przy wywołaniu sprawdzić jakie konwersje typów są dozwolone i która z funkcji może być wywyłana przy minimalnej liczbie konwersji.
Równoważność strukturalna sprowadza się do porównania, czy rekordy mają takie same pola.
Sprawdzenie musi być rekurencyjne, bo rekord może mieć pola także typu rekordowego.
Równoważność przez ma inne znaczenie gdy możemy typom nadawać inne nazwy. Na przykład deklaracja:


Jeśli przeciążamy wartość zwracaną to zamiast typów wyrażeń wyliczamy zbiory możliwych typów (możemy mieć np funkcję Date date() i int date()). Następnie kontynuujemy analizę semantyczną. Jeśli natrafimy na użycie wartości, dla którego niektóre typy są niepoprawne (np date() + 4 nie będzie działać z typem string) to zaznaczamy te niepoprawne typy. Na końcu przechodzimy drzewo składniowe jeszcze raz, eliminując niepoprawne użycia. Jeśli na końcu wyjdzie, że w pewnym miejscu możemy użyć dwóch z możliwych funkcji to zgłaszamy błąd bądź wybieramy jedną z nich na podstawie jakichś reguł.
'''type''' Calkowita = '''int'''
'''type''' WartoscPola = '''int'''


mówi, że typy ''Całkowita'' i ''WartoscPola'' są nazwami na typ '''int'''. Takie nazwy, przy równoważności przez nazwę, będą oznaczały różne typy.


== szablony (c++) i generyki (java) ==
== Przeciążanie funkcji ==


???
Język programowania może dopuszczać deklaracje kilku funkcji o tej samej nazwie lecz różniących się typami parametrów. Mechanizm ten nazywamy ''przeciążaniem funkcji''. Najprostszym przykładem przeciążonej funkcji jest operator dodawania (''+''). Jeśli jego argumentami są dwie liczby całkowite, to zostanie przeprowadzone dodawanie całkowitoliczbowe, a jeśli liczby są zmiennopozycyjne, to dodawanie zmiennopozycyjne.
Jeśli z kolei jedna liczba jest całkowita, a jedna zmiennopozycyjna, to nastąpi niejawna konwersja typów - liczba całkowita zostanie zamieniona na zmiennopozycyjną.


Przykład ten pokazuje jak kompilator wybiera funkcję, przy dostępnych kilku funkcji przeciążonych, mianowicie:


== Języki funkcyjne i wnioskowanie typów ==
* wybiera funkcję o identycznych typach argumentów,
* jeśli takiej nie ma, to sprawdza, czy dozwolone są niejawne konwersje typów podanych argumentów, takie żeby dostać typy argumentów jednej przeciążonych funkcji,
* jeśli konwersji nie ma, lub konwersje prowadzą do kilku możliwości to zgłaszany jest błąd.


* Zmienne typów;
Zwykle w przypadku kilku możliwych sposobów rzutowania argumentów wybiera się tę z najmniejszą liczbą rzutowań.
* unifikacja, etc.


= reszta =
Przeciążanie może też dotyczyć wartości zwracanej przez funkcję.


== Kontrola przepływu sterowania ==
Jeśli przeciążamy wartość zwracaną to zamiast typów wyrażeń wyliczamy zbiory możliwych typów (możemy mieć np. funkcję '''string''' date() i '''int''' date()). Następnie kontynuujemy analizę semantyczną. Jeśli natrafimy na użycie wartości, dla którego niektóre typy są niepoprawne (np date() + 4 nie będzie działać z typem string) to zaznaczamy te niepoprawne typy. Na końcu przechodzimy drzewo składniowe jeszcze raz, eliminując niepoprawne użycia. Jeśli na końcu wyjdzie, że w pewnym miejscu możemy użyć dwóch z możliwych funkcji to zgłaszamy błąd bądź wybieramy jedną z nich na podstawie jakichś reguł.


* break - sprawdzamy, czy break jest wewnątrz bloku switch, for, while, itp.
== Przykład kontroli typów ==
* goto - sprawdzamy, czy etykieta jest zadeklarowana
* this - tylko w klasie


Na poniższej animacji przedstawione jest etykietowanie drzewa składni typami wyliczanymi w czasie statycznej analizy semantycznej.


== kontrola nazw (unikalności nazw identyfikatorów, bloków, etc.) ==
<applet align="left" code="PSViewer" archive="images/d/da/Mrjp3.jar" width="600" height="550">
* czy ta sama nazwa nie została użyta w różnych kontekstach (np. jako nazwa funkcji i nazwa typu rekordowego - niektóre języki wszak dopuszczają takie użycia)
<param name="cache_archive" value="Mrjp.jar">
* słowa kluczowe - czy jako identyfikatory nie zostały wzięte słowa kluczowe (niektóre języki wszak to dopuszczają)
<param name="DIR" value="images/2-2/">
* etykiety dla goto, case - czy etykiety dla goto i case są unikalne
</applet>


== kontrola dostępu ==
= Kontrola poprawności instrukcji =
Sprawdza się użycie zmiennych klasowych.
* próby czytania zmiennych prywatnych spoza klasy
* próby czytania zmiennych chronionych spoza podklas lub pakietu


* próby zapisywania zmiennych tylko do odczytu (finalnych)
Przez "poprawność instrukcji" rozumiemy wszelką inną poprawność instrukcji poza sprawdzaniem typów i identyfikatorów. Sprawdzanie obejmuje
* kontrolę L-wartości,
* kontrolę przepływu sterowania,
* kontrolę dostępu do obiektów i klas,
* i inne.


== L-wartości ==
== L-wartości ==
Przpisania mogą być wykonane tylko do L-wartości (L-wartością nie jest np. wartość zwracana z funkcji.
Przekazywanie paraemtru przez zmienną może dotyczyć tylko L-wartości.


== inne pomysły ==
L-wartość to element, który może stać z lewej strony instrukcji przypisania, czyli do którego można przypisywać inną wartość. Przykładami L-wartości :
* Czy przy tworzeniu nowego obiektu typu tablicowego został podany rozmiar.
* czy klasa tworzona nie jest abstrakcyjna
* czy nie ma cykli w hierarchii klas
* czy nadklasa nie jest finalna
* czy w nadklasie są metody, które się przedefiniowuje. czy wirtualne, czy zwracany typ jest taki sam
* czy typ przy return zgadza się z typem funkcji


x;      // zmienna
y.a;    // pole rekordu
t[4];  // element tablicy


Z kolei wyrażenia, które nie są L-wartościami to:


5;      // stała
f(x);  // wynik funkcji
x+y;    // wynik operacji arytmetycznych


= test wiki =
Są to wartości tymczasowe i nie odpowiada im żadna lokacja w pamięci.


== tabela ==
Sprawdzanie L-wartości najłatwiej zrobić etykietując drzewo składniowe wartością logiczną określającą, czy wyrażenie jest L-wartością.


{| border=1
L-wartości kontroluje się przy instrukcji przypisania, gdzie sprawdza się, czy lewa strona jest L-wartością. Także przy wywołaniu funkcji przez zmienną (gdy funkcja może zmodyfikować przekazywaną zmienną) przekazywana musi być L-wartość.
|+ <span style="font-variant:small-caps">Uzupelnij tytul</span>
|-
| a1  ||  b1  || c1
|-
| a2  ||  b2  ||  c2


|}
== Kontrola przepływu sterowania ==
 
== podrozdz1 ==
{{kotwica|nazwakotwicy|}}


=== podrozdz ===
W ramach kontroli przepływu sterowania sprawdza się instrukcje, które zmieniają liniowy bieg programu. Przykłady kontroli:


Przykład:
* czy instrukcja przerwania pętli jest wewnątrz jakiejś pętli (np. w języku ''C'' instrukcja '''break''' musi być wewnątrz bloku '''switch''', '''for''', '''while'''),
x = y
* czy etykieta przy instrukcji '''goto''' jest zadeklarowana.
a = x + y
b = a + x


link [[Metody realizacji języków programowania/MRJP Wykład 8#martwykod|martwym kodem]].
==Kontrola obiektów i klas==


Lista punktowana
Pod pojęciem kontroli obiektów i klas mieści się ogół akcji związanych ze sprawdzeniem poprawności semantycznej obiektowych części programu. Weryfikuje się:
* ala ma kota
** aaa


adsf<math>\alpha + \sqrt{(\frac{4}{b})}</math>'''bold''' ''Italic''
* czy użycie operatora '''this''' (w niektórych językach '''self''') jest wewnątrz klasy, w metodzie niestatycznej; ta instrukcja zapewnia dostęp do zmiennych i metod obiektu dla którego wywoływana była metoda,
* czy klasa tworzonego obiektu nie jest abstrakcyjna,
* czy nie ma cykli w hierarchii klas,
* czy nadklasa nie jest finalna,
* czy w nadklasie są metody, które się przedefiniowuje (niektóre języki wymagają jawnego wyspecyfikowania, czy funkcja jest nowa, czy przeładowuje funkcję z nadklasy), czy mogą być przedefiniowane, czy zwracany typ i parametry są takie same,
* czy zmienne prywatne nie są czytane spoza klasy,
* czy zmienne chronione nie są czytane spoza pakietu bądź podklas (zależnie od semantyki języka).


[[internal link]]
== Inne ==


[external link]
Podane wcześniej miejsca sprawdzania poprawności semantycznej nie obejmują wszystkich zagadnień. Każdy język ma własną semantykę i według nie należy analizować kod źródłowy. Konstrukcje niepoprawne w jednym języku mogą być akceptowane w drugim lub ich poprawność może zostać sprawdzona dopiero w czasie uruchomienia skompilowanego programu.


[[Grafika:an image]]
Języki mogą sprawdzań na przykład:


[[Media:media file]]
* czy przy tworzeniu nowego obiektu typu tablicowego został podany rozmiar.
* czy przy tworzeniu nowego obiektu nazwa klasy odpowiada jakiejś zadeklarowanej klasie nieabstrakcyjnej
* i wiele, wiele innych rzeczy.


<nowiki>No wiki
= Kontrola nazw =
formating
</nowiki>


--[[Użytkownik:Mbiskup|Mbiskup]] 10:41, 22 lip 2006 (CEST)Mój podpis
Kontrola nazw polega na sprawdzeniu, czy nazwy identyfikatorów i etykiet w programie źródłowym są poprawne. Za niepoprawne często uważa się:


i = 0
* deklaracje dwóch zmiennych o tej samej nazwie w tym samym zakresie widoczności,
'''do''' {
* deklaracje dwóch funkcji o tej samej nazwie i tych samych parametrach w tym samym zakresie widoczności,
  j = 4 * i
* użycie niezadeklarowanej zmiennej,
  i = i + 1
* użycie tej samej nazwy w dwóch kontekstach, np. jako nazwa typu rekordu i nazwa zmiennej (choć wiele kompilatorów ma oddzielne przestrzenie nazw i takie użycie jest uważane za poprawne,
} '''while''' (i < 20)
* użycie słowa kluczowego jako identyfikatora,
* deklaracja dwóch etykiet (np. dla '''goto''' lub '''case''') o tej samej nazwie, lub brak deklaracji użytej etykiety.


= Bibliografia =
Część z tych rzeczy, np. podwójne deklaracje, jest sprawdzana jeszcze w fazie tworzenia tablicy symboli.
# elem2

Aktualna wersja na dzień 12:17, 30 wrz 2006

Autor: Marek Biskup (mbiskup@mimuw.edu.pl)

Statyczna analiza semantyczna

Statyczna analiza semantyczna ma na celu częściowe sprawdzenie poprawności kodu źródłowego programu w czasie kompilacji. Na tym etapie zakładamy, że dla programu źródłowego zostało już zbudowane drzewo składniowe oraz tablice symboli. Założenia te nie zawsze są konieczne. Niektóre kompilatory mogą przeprowadzać analizę semantyczną w czasie analizy syntaktycznej kodu, w czasie budowy tablicy symboli lub w czasie generowania kodu. Jednak nasze podejście pozwala łatwiej zrozumieć tę fazę działania kompilatora.

Głównym zadaniem w czasie analizy semantycznej jest sprawdzenie, czy program może być jednoznacznie skompilowany. Pewne konstrukcje, mimo że dopuszczalne przez gramatykę języka, mogą być niepoprawne. Poniżej zaprezentowane jest kilka przykładów niepoprawnych fragmentów kodu, dla których błędy mogą zostać wykryte w czasie analizy semantycznej. Przykłady są podane w języku C++, ale w innych językach występują te same typy błędów.

  • Użycie zmiennej, która nie była zadeklarowana:
int f(void) {
   int zmienna = 5;
   return zmianna;          // literówka!
}
  • Niezgodność typów przy przypisaniu:
void f(void) {
   string s = "tekst";
   int a = s;               // błąd typu!
}
  • Wywołanie nieistniejącej metody obiektu lub nieistniejącej funkcji:
class C {
   void print();
};
void f(void) {
   C obiekt;
   obiekt.prnt();           // literówka!
}
  • Odwołanie do zmiennych klasy z metod statycznych:
class C {
   int a;
   static int getA() {
      return a;            // błąd! - funkcja getA jest statyczna
   }
};
  • Użycie instrukcji w niedozwolonych miejscach, np. this poza klasą (choć ten problem nie występuje na przykład w Javie), break poza pętlą:
int main(int argc, char** argv) {
   if (argc > 2)
      break;                // błąd! - break poza pętlą
   else
      return this->a;       // błąd! - main nie jest metodą klasową
}
  • Przypisanie do nie L-wartości (np. do wyniku zwracanego przez funkcję lub do wyniku dodawania - więcej o L-wartościach będzie napisane w dalszej części tego wykładu):
int f(void) {
   int a = 2;
   int b = 4;
   (a + b) = 8;           // błąd! (a + b) jest tymczasową wartością 
                          // i nie odpowiada żadnej lokacji w pamięci
}

Zakres analizy semantycznej zależy od języka programowania. Niektóre języki dopuszczają użycie niezadeklarowanych zmiennych. Niektóre sprawdzają poprawność przypisania lub wywołania metody w czasie wykonywania programu, zamiast w czasie kompilacji. Pozostawienie sprawdzania poprawności na czas wykonywania programu można nazwać dynamiczną analizą semantyczną (lub częściej dynamicznym typowaniem, gdyż sprawdzanie typów jest znaczną częścią analizy semantycznej).

Łatwiej pisać skomplikowane, a jednocześnie poprawne programy jeśli kompilator dokonuje szerszej i głębszej analizy semantycznej kodu źródłowego. Dużą część błędów (np. literówki), które mogłyby powstać w czasie wykonywania programu, wykrywa się na etapie kompilacji. Dzięki temu można znacznie skrócić fazę testów oprogramowania i pisać kod z mniejszą liczbą błędów.

Z drugiej strony zaawansowana statyczna analiza semantyczna wymaga pewnego nakładu pracy ze strony programisty używającego tego języka i ogranicza możliwości języków programowania. Sama deklaracja każdej zmiennej zabiera czas programiście piszącemu kod źródłowy. Dlatego możliwość używania niezadeklarowanych zmiennych jest pożądana w językach skryptowych oraz używanych do szybkiego prototypowania. Języki z brakiem statycznego sprawdzania poprawności typów (w szczególności języki obiektowe, jak smalltalk czy python) są bardziej elastyczne. Pozwalają na skupienie się na samym zadaniu do rozwiązania, a nie na zmieszczeniu rozwiązania w ramach języka programowania. To z kolei ułatwia i przyspiesza projektowanie, zarówno w małych projektach, jak i w dużych. Projektant języka programowania musi odpowiednio wyważyć zalety i wady statycznej analizy semantycznej mając na uwadze przeznaczenie języka.

W tym rozdziale zostaną opisane najistotniejsze elementy statycznej analizy semantycznej:

  • kontrola typów, czyli sprawdzanie poprawności typów w każdym węźle drzewa składni programu (w tym także sprawdzanie, czy identyfikatory zostały zadeklarowane);
  • kontrola poprawności instrukcji, czyli sprawdzenie, czy instrukcje i wyrażenia mają sens w kontekście, w którym zostały użyte,
  • kontrola nazw, czyli sprawdzenie, czy nazwy jednoznacznie identyfikują funkcje, etykiety i inne konstrukcje języka programowania.

Ten podział nie oznacza, że analiza semantyczna jest rozbita na trzy kroki. W rzeczywistości instrukcje sprawdzane są po kolei, mając na uwadze wszystkie trzy kryteria. Niektóre sprawdzenia, jak na przykład kontrola, czy jakaś etykieta nie została zadeklarowana dwukrotnie, odnoszą się do programu jako do całości.

Kontrola typów

Kontrola typów ma na celu sprawdzenie poprawności typów w takich konstrukcjach językowych jak:

  • przypisania - typ wartości przypisywanej musi być zgodny z typem elementu do którego przypisujemy,
  • operacje arytmetyczne - wartości, do których używany jest operator arytmetyczny, muszą zgadać się z rodzajem operatora,
  • wywołania funkcji - typy parametrów funkcji przy jej wywołaniu muszą zgadzać się z typami zadeklarowanymi,
  • odwołania do pól rekordu - rekord, do którego się odwołujemy, musi mieć pole o podanej nazwie,
  • wywołania metod obiektu - obiekt musi być instancją klasy, która zawiera wywoływaną metodę.

Zgodność typów nie oznacza, że typy są identyczne, ale, że operacja może być zastosowana dla tych typów. W dalszej części tego rozdziału przedstawione będą rodzaje typów, ich zgodność oraz kontrola ich poprawności.

W czasie kontroli typów sprawdzane też jest czy identyfikatory były zadeklarowane. Do tego celu służy zbudowana wcześniej tablica symboli.

System typów

Typy obejmują:

  • typy arytmetyczne (int, char, etc.),
  • wskaźniki i referencje,
  • rekordy,
  • obiekty,
  • tablice,
  • funkcje.

Różne języki programowania mają różne systemy typów. Nie wszystkie będą zawierały wszystkie z tych typów, a niektóre będą miały system typów bardziej rozbudowany.

Typy arytmetyczne

Typy arytmetyczne są przeznaczone do bezpośredniej manipulacji przez procesor lub maszynę wirtualną. Obejmują one liczby całkowite (1, 2, 4 lub 8 bajtów), zmiennopozycyjne (4, 8 lub 10 bajtów) i znaki (1 lub 2 bajty). Nie wszystkie języki programowania dopuszczają typy o wszystkich rozmiarach, a niektóre mogą oferować bogatszy zestaw typów arytmetycznych. Procesor lub maszyna wirtualna posiada zwykle instrukcję do odczytywania tych wartości z pamięci, wykonywania na nich operacji (dodawanie, mnożenie, etc.) i zapisywanie w pamięci. Czasem jednak, na przykład dla architektur bez możliwości obliczeń zmiennopozycyjnych, operacje mogą być zamieniane na wywołania odpowiednich funkcji lub przerwań.

Wskaźniki i referencje

Referencje to małe obiekty, które przechowują informację o dostępie do innej wartości (np. do wartości zmiennej lub do obiektu) - wskazują na nią. Dzięki referencji można mieć dostęp do wskazywanej wartości. Zwykle wystarczającą informacją jest adres w pamięci.

Wskaźniki są tym samym, co referencje, ale tym razem zakłada się, że przechowują wyłącznie adres pamięci, pod którym zapisana jest inna wartość.

Używając referencji lub wskaźnika niejako tworzymy inną nazwę na istniejącą już wartość. Niech, na przykład, pod adresem 0x11111111 będzie zapisana zmienna x, która jest równa 2. Jeśli zmienna w jest wskaźnikiem na x, to wartość w będzie równa 0x11111111. Mając dostęp do zmiennej w możemy odczytać wartość zmiennej x. Jeśli teraz dokonamy przypisania:

x = 3;

to korzystając z tej samej zmiennej w możemy odczytać nową wartość równą 3.

Wskaźniki (i referencje) mogą mieć różny typ: wskaźnik na liczbę całkowitą, na liczbę zmiennopozycyjną, na obiekt, element tablicy.

Referencje i wskaźniki w rzeczywistości są tym samym. Jedyna różnica jest taka, że mówiąc wskaźnik mamy na myśli tylko adres w pamięci, a mówiąc referencja, być może również jakiś inny rodzaj dostępu (np. w systemach rozproszonych mówi się o referencji do zdalnego obiektu).

Niektóre języki programowania, na przykład C++ rozróżniają między wskaźnikami a referencjami i przypisują im nieco różną składnię i semantykę. Na przykład w C++ referencje są niezmienialne, tzn. nie można zmienić adresu na który wskazuje zainicjalizowana referencja. W innych, jak Java jest tylko jeden rodzaj i nazywa się je referencjami (słowo wskaźnik nie jest używane wśród programistów Javy).

Rekordy

Rekordy (zwane także strukturami lub krotkami) to złożone typy, które zawierają pola o innych typach. Na przykład konstrukcja z języka C:

struct A {
   int I;
   double D;
};

definiuje typ rekordowy A, który zawiera dwa pola: liczbę całkowitą I oraz liczbę zmiennopozycyjną D.

Rekord grupuje wartości różnych typów i pozwala na przechowywanie tych wartości w pojedynczej zmiennej. Odwołanie do poszczególnych pól rekordu jest wykonywane przez podanie nazwy pola (czasem numeru pola), np. a.I w języku C oznacza wzięcie pola I ze zmiennej a, która jest typu rekordowego. Operator . (kropka) oznacza dostęp do pola.

Rekordy mogą zawierać pola o dowolnych typach. Szczegóły są uzależnione od języka programowania.

Obiekty

Obiekty są jak rekordy, ale mogą przechowywać funkcje (zwane metodami). Z wnętrza takiej funkcji jest dostęp do pól obiektu, z którego funkcja została wywołana. Główną cechą obiektów jest to, że ich typy - klasy - mogą dziedziczyć po innych klasach. Zainteresowanego czytelnika odsyłamy do książek poświęconych podstawom programowania obiektowego.

Tablice

Tablice to ciągły obszar pamięci zawierający kolejne instancje typu tablicy. Na przykład tablica 100 liczb całkowitych to obszar pamięci zawierający kolejno 100 liczb całkowitych. Mogą być tablice obiektów, referencji, tablic, etc.

Funkcje

W przypadku funkcji też możemy mówić o typach. Typy funkcji są takie same, jeśli funkcja przyjmuje tę samą liczbę argumentów, argumenty są tego samego typu oraz typ wartości zwracanej test taki sam. Typ funkcji można zapisać w postaci sygnatury typu funkcji:

Typ_Arg1, Typ_Arg2, ..., Typ_ArgN -> Typ_Wart

Liczba N oznacza liczbę argumentów, Typ_Argi to typ i-tego argumentu, a Typ_Wart to typ wartości. Jeśli na przykład w języku C mamy funkcję:

double funkcja(char a, int* p);

to jej typem jest:

char, int* -> double

Jak widać, aby sprawdzić, czy dwie funkcje są tego samego typu, wystarczy porównać ich sygnatury.

W przypadku języków proceduralnych lub obiektowych (ogólnie - w językach imperatywnych) rzadko używa się zmiennych, których typem jest funkcja (taka zmienna wtedy zwykle przechowuje adres, pod którym jest kod funkcji). Jednak w językach funkcyjnych zmienne bardzo często przechowują funkcje. Funkcje są w tych językach pełnoprawnymi wartościami i można na nich przeprowadzać operacje, które mogą zmienić ich typ (na przykład mając funkcję biorącą liczbę całkowitą i znak oraz dającą liczbę zmiennopozycyjną można zaaplikować jeden z argumentów, np. liczbę całkowitą; wynikiem aplikacji będzie funkcja, która oczekuje znaku i daje liczbę zmiennopozycyjną). Dlatego w językach funkcyjnych bardzo ważna jest analiza typów funkcji. Więcej o językach funkcyjnych jest napisane w rozdziale 12.

Konwersja typów

Wartości niektórych typów mogą w pewnych sytuacjach być konwertowane do wartości innego typu. Na przykład, jeśli oczekujemy liczby całkowitej 4-bajtowej, a mamy 2 bajtową, to możemy rozszerzyć ją o brakujące dwa bajty nie gubiąc przy tym żadnej informacji.

Taka niejawna konwersja typów zależy od kompilatora. Często obejmuje ona:

  • konwersję liczb całkowitych do zmiennoprzecinkowych
  • konwersja liczb całkowitych do liczb całkowitych o większej precyzji
  • konwersja liczb zmiennoprzecinkowych do zmiennoprzecinkowych o większej precyzji
  • konwersja typu obiektu do nadklasy (gdyż podklasa może być użyta wszędzie tak, gdzie oczekujemy nadklasy

Dobrze zaprojektowany język powinien zabraniać niejawnej konwersji, przy której może wystąpić utrata informacji. Z kolei niejawna konwersja, przy której nie ma utraty informacji, znacząco ułatwia pracę programiście.

Przy sprawdzaniu typów, przed zgłoszeniem błędu niezgodności typów, kompilator powinien zawsze sprawdzić, czy podany typ może być poddany konwersji niejawnej. Może również zajść sytuacja, w której konwersja jest możliwa na dwa różne sposoby. Wtedy semantyka dobrze zaprojektowanego języka powinna określać priorytety konwersji typów lub określać, że taka niejednoznaczność jest błędem.

Oprócz niejawnej konwersji typów, języki programowania często pozwalają programiście na jawną zmianę typu elementu. W przypadku konwersji zmiennej rzeczywistej na całkowitą oznacza to utratę precyzji (ale zaakceptowaną przez programistę, gdyż jawnie zaznaczył ją w programie). W przypadku konwersji typów obiektów (zwanej rzutowaniem) może to prowadzić do błędów, gdy konwertujemy typ obiektu na klasę całkowicie nie związaną z poprzednią klasą. Takie błędy nie są możliwe do wykrycia na etapie kompilacji. Projektant języka może dać możliwość sprawdzenia takiego rzutowania w czasie wykonania programu. Takie podejście zostało zastosowane w języku Java. Pomaga to w wykryciu błędów, ale jednocześnie spowalnia nieco wykonanie programu. Z kolei w C++ poprawność rzutowania nie jest domyślnie sprawdzana. Dzięki temu kod jest szybszy, ale istnieje ryzyko pojawienia się trudnych do lokalizacji błędów (w C++ da się jednak zaznaczyć, czy chcemy dynamicznie sprawdzać poprawność rzutowania obiektów).

Sprawdzanie poprawności typów

Sprawdzanie poprawności typów polega na przejściu drzewa składniowego, sprawdzeniu w każdym węźle, czy jego operacja może być zastosowana do typów dzieci tego węzła i w końcu na określeniu typu samego węzła. Przy przechodzeniu drzewa korzysta się z tablicy symboli do określenia typów zmiennych, funkcji, pól rekordów, etc.

Aby lepiej zrozumieć analizę semantyczną spójrzmy na prosty przykład. Chcemy sprawdzić fragment drzewa odpowiadający instrukcji

x = y + 5;

W tablicy zaczynamy od liści drzewa składniowego. Dla zmiennych x i y typy sprawdzamy w tablicy symboli. Jeśli x lub y nie zostało zadeklarowane to zgłaszamy błąd niezadeklarowanej zmiennej. Przypuśćmy, że typem x jest double, czyli liczba zmiennoprzecinkowa podwójnej precyzji, a typem y jest int, czyli liczba całkowita. Typem węzła odpowiadającego stałej 5 jest również int. Sprawdzamy węzeł '+': typami dzieci tego węzła są int oraz int, więc operacja dodawania może być przeprowadzona. Wynikiem jest typ int. Sprawdzamy węzeł '=' (przypisanie): typem lewej strony jest double, a prawej int. Zakładamy, że nasz kompilator pozwala na przypisanie zmiennej całkowitej do zmiennoprzecinkowej więc całą instrukcję uznajemy za poprawną. Jako typ całości bierzemy double, czyli typ zmiennej na którą przypisujemy (jest to semantyka wzięta z języka C, który dopuszcza przypisania w postaci x = y = 1; alternatywnie moglibyśmy takich przypisań zabronić i jako typ wyniku wziąć void - specjalny typ, który nie niesie żadnej wartości).

<applet align="left" code="PSViewer" archive="images/d/da/Mrjp3.jar" width="600" height="550"> <param name="cache_archive" value="Mrjp.jar"> <param name="DIR" value="images/1-2/"> </applet>

Na powyższym przykładzie widzimy, że analiza semantyczna jest dość skomplikowana, ale można ją rozbić na małe fragmenty - każdy odpowiada pojedynczemu węzłowi w drzewie składniowym.

W podpunktach niżej omówione zostaną reguły odpowiadające pewnym węzłom w drzewie składniowym.

Przypisania

Przy przypisaniach typ wartości, którą przypisujemy, musi być zgodny z typem elementu, do którego przypisujemy. Zgodność w tym przypadku może być ścisła - ten sam typ, lub może być dozwolona niejawna konwersja typów. Konwersja może pozwolić na przypisanie na przykład wartości całkowitej na liczbę zmiennopozycyjną, wartości całkowitej na ciąg znaków (dostaniemy liczbę zapisaną w kodzie ASCII).

W językach programowania obiektowego przyjmuje się, że do zmiennej zadeklarowanej jako zmienna typu T można przypisywać obiekty podklas klasy T. Kontrola poprawności przypisań musi uwzględniać możliwość takiego przypisania.

Operacje arytmetyczne

W operacjach arytmetycznych wartości, do których używany jest operator arytmetyczny, muszą pasować do rodzaju operatora. Na przykład pomnożenie dwóch liczb całkowitych jest dozwolone, a pomnożenie dwóch stałych napisowych nie.

Niektóre języki dopuszczają definiowanie własnej semantyki operatorów arytmetycznych. W takim przypadku programista zwykle pisze kod funkcji, która będzie wywołana zamiast przeprowadzania operacji arytmetycznej. W takim przypadku sprawdzanie semantyki przebiega tak jak opisane niżej sprawdzanie semantyki wywołania funkcji.

Wywołania funkcji

Przy wywołaniach funkcji typy parametrów muszą zgadzać się z typami określonymi w deklaracji funkcji. Jako wynik wywołania funkcji bierze się typ zwracany przez funkcję.

Ze sprawdzaniem semantyki wywołania funkcji związane jest także sprawdzanie semantyki powrotu z funkcji (choć jest to sprawdzane w innym miejscu kodu źródłowego - przy analizie semantycznej kodu funkcji). Przy analizie semantycznej powrotu z funkcji (zwykle jest to instrukcja return) należy sprawdzić, czy typ wartości zwracanej przez funkcję (zwykle jest to wyrażenie policzone wewnątrz funkcji, np w instrukcji return <Expr>, gdzie <Expr> oznacza wyrażenie) jest zgodny z zadeklarowanym typem wyniku funkcji.

Odwołania do pól rekordu

Przy odwołaniach do pól rekordu należy przede wszystkim sprawdzić, czy element, do którego pola się odwołujemy jest naprawdę rekordem (jeśli jest on np. liczbą całkowitą to takie odwołanie jest błędne) oraz, czy taki rekord rzeczywiście zawiera pole o tej nazwie. Nazwy pól rekordów są dostępne w tablicy symboli. Typem zapisanym w węźle drzewa składniowego będzie typ pola rekordu.

Wywołania metod obiektu

Analiza jest taka sama, jak w przypadku odwołania do pól rekordu i wywołaniu funkcji.

Równość typów

Przy sprawdzaniu poprawności typów trzeba sprawdzić, czy typ węzła jest taki jak oczekujemy (np. taki sam jak typ parametru funkcji, która jest wołana na wyrażeniu). Trzeba więc porównać typy. W przypadku typów arytmetycznych wystarczy sprawdzić, czy nazwa typu jest taka sama (i oczywiście uwzględnić automatyczną konwersję typów). W przypadku typów rekordowych są dwie możliwości:

  • równoważność strukturalna
  • równoważność przez nazwę

Równoważność strukturalna sprowadza się do porównania, czy rekordy mają takie same pola. Sprawdzenie musi być rekurencyjne, bo rekord może mieć pola także typu rekordowego. Równoważność przez ma inne znaczenie gdy możemy typom nadawać inne nazwy. Na przykład deklaracja:

type Calkowita = int
type WartoscPola = int

mówi, że typy Całkowita i WartoscPola są nazwami na typ int. Takie nazwy, przy równoważności przez nazwę, będą oznaczały różne typy.

Przeciążanie funkcji

Język programowania może dopuszczać deklaracje kilku funkcji o tej samej nazwie lecz różniących się typami parametrów. Mechanizm ten nazywamy przeciążaniem funkcji. Najprostszym przykładem przeciążonej funkcji jest operator dodawania (+). Jeśli jego argumentami są dwie liczby całkowite, to zostanie przeprowadzone dodawanie całkowitoliczbowe, a jeśli liczby są zmiennopozycyjne, to dodawanie zmiennopozycyjne. Jeśli z kolei jedna liczba jest całkowita, a jedna zmiennopozycyjna, to nastąpi niejawna konwersja typów - liczba całkowita zostanie zamieniona na zmiennopozycyjną.

Przykład ten pokazuje jak kompilator wybiera funkcję, przy dostępnych kilku funkcji przeciążonych, mianowicie:

  • wybiera funkcję o identycznych typach argumentów,
  • jeśli takiej nie ma, to sprawdza, czy dozwolone są niejawne konwersje typów podanych argumentów, takie żeby dostać typy argumentów jednej przeciążonych funkcji,
  • jeśli konwersji nie ma, lub konwersje prowadzą do kilku możliwości to zgłaszany jest błąd.

Zwykle w przypadku kilku możliwych sposobów rzutowania argumentów wybiera się tę z najmniejszą liczbą rzutowań.

Przeciążanie może też dotyczyć wartości zwracanej przez funkcję.

Jeśli przeciążamy wartość zwracaną to zamiast typów wyrażeń wyliczamy zbiory możliwych typów (możemy mieć np. funkcję string date() i int date()). Następnie kontynuujemy analizę semantyczną. Jeśli natrafimy na użycie wartości, dla którego niektóre typy są niepoprawne (np date() + 4 nie będzie działać z typem string) to zaznaczamy te niepoprawne typy. Na końcu przechodzimy drzewo składniowe jeszcze raz, eliminując niepoprawne użycia. Jeśli na końcu wyjdzie, że w pewnym miejscu możemy użyć dwóch z możliwych funkcji to zgłaszamy błąd bądź wybieramy jedną z nich na podstawie jakichś reguł.

Przykład kontroli typów

Na poniższej animacji przedstawione jest etykietowanie drzewa składni typami wyliczanymi w czasie statycznej analizy semantycznej.

<applet align="left" code="PSViewer" archive="images/d/da/Mrjp3.jar" width="600" height="550"> <param name="cache_archive" value="Mrjp.jar"> <param name="DIR" value="images/2-2/"> </applet>

Kontrola poprawności instrukcji

Przez "poprawność instrukcji" rozumiemy wszelką inną poprawność instrukcji poza sprawdzaniem typów i identyfikatorów. Sprawdzanie obejmuje

  • kontrolę L-wartości,
  • kontrolę przepływu sterowania,
  • kontrolę dostępu do obiektów i klas,
  • i inne.

L-wartości

L-wartość to element, który może stać z lewej strony instrukcji przypisania, czyli do którego można przypisywać inną wartość. Przykładami L-wartości są:

x;      // zmienna
y.a;    // pole rekordu
t[4];   // element tablicy

Z kolei wyrażenia, które nie są L-wartościami to:

5;      // stała
f(x);   // wynik funkcji
x+y;    // wynik operacji arytmetycznych

Są to wartości tymczasowe i nie odpowiada im żadna lokacja w pamięci.

Sprawdzanie L-wartości najłatwiej zrobić etykietując drzewo składniowe wartością logiczną określającą, czy wyrażenie jest L-wartością.

L-wartości kontroluje się przy instrukcji przypisania, gdzie sprawdza się, czy lewa strona jest L-wartością. Także przy wywołaniu funkcji przez zmienną (gdy funkcja może zmodyfikować przekazywaną zmienną) przekazywana musi być L-wartość.

Kontrola przepływu sterowania

W ramach kontroli przepływu sterowania sprawdza się instrukcje, które zmieniają liniowy bieg programu. Przykłady kontroli:

  • czy instrukcja przerwania pętli jest wewnątrz jakiejś pętli (np. w języku C instrukcja break musi być wewnątrz bloku switch, for, while),
  • czy etykieta przy instrukcji goto jest zadeklarowana.

Kontrola obiektów i klas

Pod pojęciem kontroli obiektów i klas mieści się ogół akcji związanych ze sprawdzeniem poprawności semantycznej obiektowych części programu. Weryfikuje się:

  • czy użycie operatora this (w niektórych językach self) jest wewnątrz klasy, w metodzie niestatycznej; ta instrukcja zapewnia dostęp do zmiennych i metod obiektu dla którego wywoływana była metoda,
  • czy klasa tworzonego obiektu nie jest abstrakcyjna,
  • czy nie ma cykli w hierarchii klas,
  • czy nadklasa nie jest finalna,
  • czy w nadklasie są metody, które się przedefiniowuje (niektóre języki wymagają jawnego wyspecyfikowania, czy funkcja jest nowa, czy przeładowuje funkcję z nadklasy), czy mogą być przedefiniowane, czy zwracany typ i parametry są takie same,
  • czy zmienne prywatne nie są czytane spoza klasy,
  • czy zmienne chronione nie są czytane spoza pakietu bądź podklas (zależnie od semantyki języka).

Inne

Podane wcześniej miejsca sprawdzania poprawności semantycznej nie obejmują wszystkich zagadnień. Każdy język ma własną semantykę i według nie należy analizować kod źródłowy. Konstrukcje niepoprawne w jednym języku mogą być akceptowane w drugim lub ich poprawność może zostać sprawdzona dopiero w czasie uruchomienia skompilowanego programu.

Języki mogą sprawdzań na przykład:

  • czy przy tworzeniu nowego obiektu typu tablicowego został podany rozmiar.
  • czy przy tworzeniu nowego obiektu nazwa klasy odpowiada jakiejś zadeklarowanej klasie nieabstrakcyjnej
  • i wiele, wiele innych rzeczy.

Kontrola nazw

Kontrola nazw polega na sprawdzeniu, czy nazwy identyfikatorów i etykiet w programie źródłowym są poprawne. Za niepoprawne często uważa się:

  • deklaracje dwóch zmiennych o tej samej nazwie w tym samym zakresie widoczności,
  • deklaracje dwóch funkcji o tej samej nazwie i tych samych parametrach w tym samym zakresie widoczności,
  • użycie niezadeklarowanej zmiennej,
  • użycie tej samej nazwy w dwóch kontekstach, np. jako nazwa typu rekordu i nazwa zmiennej (choć wiele kompilatorów ma oddzielne przestrzenie nazw i takie użycie jest uważane za poprawne,
  • użycie słowa kluczowego jako identyfikatora,
  • deklaracja dwóch etykiet (np. dla goto lub case) o tej samej nazwie, lub brak deklaracji użytej etykiety.

Część z tych rzeczy, np. podwójne deklaracje, jest sprawdzana jeszcze w fazie tworzenia tablicy symboli.