Podrozdziały


7.4 Modyfikatory typów

Modyfikatory volatileconst są tzw. modyfikatorami typu (ang. type qualifier): nie wpływają na sposób przydziału pamięci czy linkowania, ale zmieniają typ deklarowanej zmiennej (choć reprezentacja bitowa pozostaje taka sama).


7.4.1 Zmienne ulotne

Zmienne ulotne deklarowane są z modyfikatorem volatile, np.:

       volatile double x;
Modyfikator volatile oznacza, że zmienna ta może ulec zmianie „bez wiedzy” programu, np. na skutek obsługi przerwań generowanych przez urządzenia zewnętrzne (czujniki, mierniki itd.) czy obsługi sygnałów. Zadeklarowanie zmiennej jako ulotnej stanowi wskazówkę dla kompilatora, że przed każdym użyciem należy wartość tej zmiennej odczytać na nowo z pamięci, a każde na nią przypisanie (modyfikacja jej wartości) musi być fizycznie wykonane przed wykonaniem kolejnych instrukcji programu. Normalnie bowiem, widząc, że zmienna nie ulega zmianie pomiędzy pewnymi dwoma punktami programu, kompilator może zachować jej wartość w rejestrze i przy drugim użyciu nie odczytywać jej już z pamięci.

Stosowanie volatile rzadko bywa potrzebne w „normalnych” programach. Pamiętać też warto, że nieuzasadnione deklarowanie zmiennych jako ulotnych obniża wydajność programu: utrudnia kompilatorowi optymalizację i powoduje narzuty czasowe podczas wykonania.

Nie jest również prawdą, że używanie zmiennych ulotnych przydaje się przy pisaniu programów wielowątkowych, bowiem szczegóły ich użycia są (i mają prawo być) zależne od implementacji i jako takie są nieprzenośne.


7.4.2 Stałe

Stałe (zmienne ustalone) są używane często i w wielu kontekstach.

Zmienną każdego typu, zarówno „zwykłego”, jak i wskaźnikowego, można ustalić deklarując ją z modyfikatorem typu const przed lub za nazwą typu.

Wartości stałej (zmiennej ustalonej) nie można zmienić po jej utworzeniu.

Jedynym sposobem na nadanie stałej jej wartości jest inicjalizacja podczas jej definiowania, bezpośrednio w instrukcji deklaracyjnej:

       const double PI = 3.1415926536;
Błędem byłaby natomiast inicjalizacja po utworzeniu
       const double PI;
       PI = 3.1415926536; // NIE !!!
Po utworzeniu i inicjalizacji wartości stałej nie można zmienić.

Niektóre stałe mogą mieć nadane wartości już na etapie kompilacji. Aby tak było, stała musi być zainicjowana wyrażeniem, którego wartość może być obliczona na etapie kompilacji, a więc składającego się z literałów, innych stałych wyliczalnych na etapie kompilacji i prostych operacji arytmetycznych na liczbach całkowitych. Inne stałe wyliczane są dopiero podczas wykonania.

       #include <cmath>
       // ...
       const double x1 = 2*11;
       const double x2 = x1 / 5.0;
       const double x3 = sin(x2);
Jak widzimy, na przykład z ostatniej z powyższych deklaracji, inicjowanie stałej może wiązać się z wywołaniem funkcji — oczywiście taka inicjalizacja nie będzie dokonana na etapie kompilacji, a dopiero w czasie wykonania.

Zmienna zadeklarowana jako stała ma inny typ niż zmienna nieustalona. Tak więc typem zmiennej x1 z poprzedniego przykładu jest nie double, ale const double — są to różne typy. Na przykład poniższy fragment jest nielegalny

       const int i = 25;
       int *pi = &i; // NIE !!!
gdyż typem ' &i' jest wskaźnik do ustalonej zmiennej całkowitej, a zadeklarowanym typem zmiennej pi jest wskaźnik do (nieustalonej) zmiennej całkowitej.

Przypisanie odwrotne jednak jest legalne: można do wskaźnika do ustalonej zmiennej przypisać adres zmiennej nieustalonej. Takie zachowanie jest korzystne i często się stosuje przy przekazywaniu argumentów wskaźnikowych do funkcji: wysyłamy do funkcji adres zmiennej nieustalonej, ale odpowiednim parametrem funkcji jest wskaźnik do stałej. Zapobiega to przypadkowym zmianom wartości zmiennej wewnątrz funkcji. W następującym przykładzie


P39: stale.cpp     Stałe

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int fun(const int * pi) {
      5.      //*pi = 2 * (*pi); // NIE !!!
      6.      return *pi;
      7.  }
      8.  
      9.  int main() {
     10.      int i = 2;
     11.      int res = fun(&i);                
     12.      cout << "res = " << res << endl;
     13.  }

wykomentowana linia byłaby nielegalna. Do funkcji posłaliśmy co prawda adres zwykłej zmiennej typu int (), ale funkcja o tym nie wie; parametr zadeklarowany jest jako const int*, więc kompilator nie zgodzi się na zmianę wartości zmiennej wskazywanej przez pi. Tak więc wewnątrz funkcji zmienna skojarzona z parametrem typu ustalonego jest traktowana jako stała niezależnie od tego czy odpowiedni argument wywołania był czy nie był ustalony.

Z drugiej strony, takie zachowanie może prowadzić do pewnych niejasności. Na przykład w programie


P40: stale1.cpp     Jeszcze o stałych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int main() {
      5.      int i = 2;
      6.      const int *pi = &i;                
      7.  
      8.      i = 2*i;                           
      9.      cout << "  i = " <<   i << endl;
     10.      cout << "*pi = " << *pi << endl;
     11.      //*pi = -1;
     12.  }

zmienna i nie jest ustalona. Ale pi (linia ) jest wskaźnikiem typu const int*, czyli zmienna wskazywana przez pi jest traktowana jako ustalona. Tą zmienną wskazywaną jest ta sama zmienna i. Czy zatem i jest, czy nie jest ustalona? Otóż jest, jeśli odwołujemy się do niej poprzez wskaźnik pi — dlatego wykomentowana ostatnia linia jest nielegalna. Z drugiej strony, jeśli odwołujemy się do tej samej zmiennej poprzez nazwę i, to wartość tej zmiennej możemy zmienić (linia ), bo ta zmienna wcale nie była zadeklarowana jako stała. Dlatego program jest prawidłowy i drukuje

      i = 4
    *pi = 4

Pewnej uwagi wymaga staranne rozróżnianie wskaźników do stałych od stałych wskaźników. W przykładach, które rozpatrywaliśmy, to wskazywana zmienna była ustalona, a nie wskaźnik. Zatem to wartości wskazywanej zmiennej nie można było zmienić; nic nie stało jednak na przeszkodzie, aby zmienić wartość wskaźnika, czyli zapisany w nim adres. Ilustruje to następujący przykład:


P41: stale2.cpp     Stałe zmienne wskazywane

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int main() {
      5.      const int i = 2, j = 3;
      6.  
      7.      const int *p = &i;              
      8.      cout << "*p = " << *p << endl;
      9.  
     10.      p = &j;
     11.      cout << "*p = " << *p << endl;
     12.  }

Obie zmienne i oraz j są ustalone. Wskaźnik p jest wskaźnikiem do stałej. Natomiast sam wskaźnik nie jest ustalony, a więc może wskazywać zarówno na i jak i na j — jego wartość (przechowywany adres) w trakcie wykonywania programu zmienia się. Zauważmy, że w linii nie była wcale potrzebna inicjalizacja: linia ta definiuje wskaźnik, a ten ustalony nie jest.

Można jednak ustalić wskaźnik. Modyfikator const stawiamy wówczas za znakiem gwiazdki. A zatem

       const int *pi;
lub
       int const *pi;
to deklaracja/definicja zwykłego (nieustalonego) wskaźnika do stałej całkowitej. Dlatego nie wymaga inicjowania (zauważmy, że const może być umieszczone przed lub za nazwą typu int). Natomiast
       int i;
       int *const pi = &i;
deklaruje w drugim wierszu ustalony wskaźnik. Zainicjowanie takiego wskaźnika jest więc konieczne. Zainicjowanie polega na wpisaniu do wskaźnika adresu istniejącej zmiennej (w przykładzie jest to zmienna i): wartości wskaźnika pi nie będzie można już zmienić, czyli nie będzie można wpisać do niego adresu innej zmiennej — będzie on zawsze wskazywał na zmienną i. Ale, co ważne, sama zmienna wskazywana ustalona tu nie jest — jej wartość można zmienić również poprzez nazwę wskaźnika (np. ' *pi=7;').

Rozpatrzmy następujący program, ilustrujący dotychczasową dyskusję (program ten jest błędny i nie kompiluje się!):


P42: stalwsk.cpp     Stałe wskaźniki i wskaźniki do stałych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int main() {
      5.      int *p, k = 5, m = 7;
      6.  
      7.      const int stala = 3;       // stała
      8.      const int *q = &k;         // q - wskaźnik na stałą
      9.      int *const r = &k;         // r - stały wskaźnik
     10.      const int tab[] = {1,2,3}; // tablica stałych
     11.  
     12.      p = &stala;    
     13.      stala = 1;     
     14.      *q = m;        
     15.      q = &m;        
     16.      r = &m;        
     17.      k = 10;        
     18.      tab[1] = 9;    
     19.  }

Przeanalizujmy poszczególne fragmenty:

Można też posługiwać się ustalonym wskaźnikiem do stałej:

       int i;
       const int *const pi = &i;
Oczywiście, wskaźnik musimy wtedy od razu zainicjować, choć, jak mówiliśmy, niekoniecznie adresem stałej. W takim przypadku nie wolno modyfikować ani zmiennej wskazywanej przez ten wskaźnik (w każdym razie nie wolno jej zmienić poprzez ten wskaźnik), ani samego wskaźnika (czyli adresu w nim zapisanego); na przykład w programie


P43: stalstal.cpp     Stałe wskaźniki do stałych

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int main() {
      5.      int i = 1, m = 2;
      6.      const int *const pi = &i;
      7.  
      8.      cout << "Przed *pi = " << *pi << endl;
      9.      i = 3;                                     
     10.      cout << "Po    *pi = " << *pi << endl;
     11.  
     12.      *pi = 4; // NIE: pi jest wskaźnikiem do stałej
     13.      pi = &m; // NIE: pi jest ustalonym wskaźnikiem
     14.  }

można zmienić wartość zmiennej i odwołując się do niej poprzez nazwę i, gdyż nie jest to stała (linia). Natomiast nie jest legalna ani próba zmiany tej samej wartości poprzez odwołanie się do niej za pomocą wskaźnika pi, ani próba zmiany wskazania (adresu) w zmiennej pi (dwie ostatnie linie).

W nowym standardzie C++11 istnieje „silniejsza” wersja określenia stałości — zamiast const używamy przy definiowaniu zmiennej słowa kluczowego constexpr. Znaczy to, że definiujemy coś (wartość całkowitą, zmiennoprzecinkową, obiektową), czego wartość musi dać się obliczyć już na etapie kompilacji. Obliczana wartość może zależeć od wartości literałów, ale też od wartości innych zmiennych, jeśli również są wyrażeniami stałymi (constexpr). Kompilator sprawdzi, czy rzeczywiście potrafi taką wartość wyliczyć i zgłosi błąd, jeśli jest to niemożliwe. W poniższym przykładzie


P44: constexpr.cpp     Wartości constexpr

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  int main() {
      5.      constexpr int hourfee = 7;
      6.      constexpr int tim = 5;
      7.  
      8.      int arr[10 + (tim-1)*hourfee];       
      9.  
     10.      cout << "number of elements in arr "
     11.           << sizeof(arr)/sizeof(arr[0]) << endl;
     12.  }

wyrażenie użyte do wymiarowania definiowanej tablicy jest stałą kompilacji i rzeczywiście jest obliczane na etapie kompilacji — w przeciwnym przypadku program nie skompilowałby się, bo wymiar definiowanej tablicy statycznej musi być znany kompilatorowi (przynajmniej przy kompilacji z opcją '-pedantic').

Zobaczymy w następnych rozdziałach, że wartościami constexpr mogą być też wywołania funkcji, konstrukcje obiektów czy wywołania metod na rzecz obiektów, co było niemożliwe w starym stadardzie.

T.R. Werner, 21 lutego 2016; 20:17