Blog - Programowanie C/C++

Dlaczego warto używać modyfikatora const

Opublikowane Grudzień 2011 przez Włodzimierz Gromadzki w Programowanie C/C++.

Podczas robienia porządków na półkach wpadł mi w ręce oryginalny egzemplarz, wydanej w USA książki: „The C Programming Language”, którą napisali Brian W. Kernighan & Dennis M. Ritchie. Łza mi się w oku zakręciła, bo to był przecież mój elementarz "C". Przeglądając zawartość zauważyłem ze zdziwieniem, że w zestawie słów kluczowych języka brak jest słowa const. Kiedy zostało ono dodane do języka – nie pamiętam. Ale czy to ważne? Ważne jest coś innego – mianowicie fakt częstego pomijania tego ważnego atrybutu w kodach źródłowych tworzonych przez wielu programistów - wystarczy obejrzeć przykładowe kody programów open source w Internecie. Dlatego w tym artykule chciałbym zwrócić uwagę na istotne, moim zdaniem, aspekty stosowania słowa kluczowego const.

Czy da się napisać działające programy nie używając const? Oczywiście, że tak. Nie oznacza to jednak, że taki styl pisania jest dobry. Niedocenianie wartości tego słowa skutkuje powstaniem kodu źródłowego o niskiej jakości. Aby to uzasadnić opiszę najpierw do czego słowo kluczowe const jest stosowane.

Pierwszy przykład to definicja stałej np.:

const int    alfa = 17;

const int    beta = 4*35;

To oczywiście wie każdy, ale "starzy maniacy" stosowania makr wolą tak:

#define ALFA    17

#define BETA    4*35

Obie możliwości są oczywiście składniowo i semantycznie poprawne, a nawet dają w wyniku ten sam kod binarny, lecz dobry styl pisania programu zaleca stosowanie pierwszego sposobu. Dlaczego? Dlatego, że gdzie tylko to możliwe rezygnujemy z makrodefinicji, gdyż nie są one 'type-safe' – czyli nie zapewniają kontroli typów przez kompilator. W wyżej wymienionym, bardzo prostym przykładzie, trudno wykazać niebezpieczeństwa stosowania makrodefinicji, jednak w bardziej skomplikowanych przykładach byłoby to już widać.

Różnica między makrem a stałą jest taka, że możemy utworzyć wskaźnik (pointer) na stałą alfa, a nie możemy tego zrobić z ALFA. Powód jest prosty – stała alfa w zasadzie rezyduje w pamięci operacyjnej podczas wykonywania programu. Poza tym, jeśli o to zadbamy, to będzie ona rezydować w pamięci o atrybucie READ_ONLY, co daje dodatkowe bezpieczeństwo działania programu. Dodatkowe, ponieważ każda próba niezamierzonej modyfikacji takiej stałej spowoduje wygenerowanie exception ochrony pamięci. Bez tej protekcji istnieje możliwość modyfikacji stałej alfa np. przez nadpisanie. ALFA zaś jest jedynie sposobem na podstawienie tekstu przypisanego przez makrodefinicje wszędzie tam gdzie wystąpi tekst ALFA np.:

z = x + y*ALFA;   // jest tożsame z zapisem z = x + y*17;

Ponieważ w języku C/C++ nie można utworzyć wskaźnika na liczbę, konstrukcje:

int * ptr = &ALFA; // Error;

int * ptr =   &17; // Error;

są niepoprawne składniowo.

Wracając jednak do stosowania modyfikatora const: dużo ważniejszym miejscem na jego stosowanie jest deklaracja metody składowej w  klasach. Przykład:

class  CMyClass

  {

   public  int    GetX () const;

   private int     m_X;  const;

  };

Dlaczego to jest takie ważne miejsce? Są co najmniej trzy powody:

Pierwszym, oczywistym i opisywanym przez Reference języka jest fakt, że taka deklaracja mówi kompilatorowi iż w środku funkcji GetX() na pewno nie zmienimy żadnej danej z instancji klasy CMyClass. Ta informacja pozwala kompilatorowi lepiej optymalizować kod programu - czasami dość znacznie – gdyż pozwala np. przechować w rejestrach wartości danych obiektu. Bez modyfikatora const, po powrocie z funkcji GetX() kompilator musi odświeżyć wartości w takich rejestrach, zaś mając gwarancje, że GetX() nic nie zmienia w obiekcie - nie musi tego robić.

Drugim, ważniejszym z ludzkiego punktu widzenia, powodem jest fakt, że programista czytając kod źródłowy programu może, bez zaglądania w kod funkcji GetX(), być pewnym, że nie modyfikuje ona obiektu. Jeśli przykładowo szukamy miejsca, w którym m_X zmienia wartość z 6 na 14, to funkcję GetX() traktujemy jako nieistotną z tego punktu widzenia. Natomiast brak const wymusza przejrzenie kodu funkcji GetX() czy to nie w niej jest np. zdanie:

m_X += 8;

Może to ułatwić testowanie programu i przyspiesza poszukiwanie błędu, szczególnie w dużych i złożonych programach.

Przed podaniem powodu trzeciego załóżmy, że w 100% trzymamy się zasady: "Wszystko to co może być const, musi być const".

Jeśli teraz w deklaracji klasy zobaczymy, że metoda

char  JakasFunkcja();

nie posiada atrybutu const to oznacza, że na 100% zmienia coś w obiekcie. Ta cecha wystąpi jeszcze bardziej jawnie za chwilę.

Przykład:

Bool MyStringCopy (char * dest, const char * src);

Bool MyStringCopy (char * a, char * b);

Bool MyStringCopy (char * dest, char * src);

Pierwsza deklaracja, nawet bez opisu, mówi nam, że funkcja kopiuje dane z src do dest. Druga nie jest już taka intuicyjna – jeśli nazwy parametrów niewiele nam mówią, jesteśmy zmuszeni do zajrzenia bądź do opisu funkcji, bądź do kodu jeśli taki mamy. Trzecia deklaracja daje nam co prawda podstawę do przypuszczenia, że kopiujemy z src do dst, ale pewności nadal nie ma. Ponadto jeśli nawet autor MyStringCopy nie stroił sobie żartów dając mylące nazwy parametrów, to nadal nie wiemy czy przypadkiem po przykładnym skopiowaniu danych MyStringCopy nie robi dodatkowo np. Reverse stringu src. Jeżeli my i autor tej funkcji trzymamy się zasady "wszystko to co może być const, musi być const" to widząc trzecią wersję deklaracji musimy zajrzeć do opisu funkcji, aby doczytać jaką to "dodatkową funkcjonalność" zaaplikował nam jej twórca – a na pewno coś robi ze stringiem src skoro nie dodał mu modyfikatora const.

Następny przykład zastosowania const:

float Obliczania(const float posX,const float posY)

  {

   float z = 3.f;

   ...................

    // kod - ciało funkcji

   z += Dodaj(&posX, &posY);

   ...................

   return z;

  }

Ten rodzaj zastosowania nie jest zbyt częsty, ale może być pomocny w bardzo złożonych funkcjach. Daje nam gwarancję, że w całym kodzie tej funkcji nie ma instrukcji modyfikującej parametr posX czy posY. W małych, kilkuliniowych funkcjach, bez problemu wzrokowo widzimy wszystkie miejsca użycia tych parametrów. Gdy jednak funkcja jest długa i skomplikowana możemy takie miejsce przeoczyć. W takim przypadku const jest idealnym rozwiązaniem, a każda próba modyfikacji takiego parametru zostanie potraktowana przez kompilator jako błąd. Ba – nawet próba przekazania takiego parametru przez wskaźnik lub referencje zostanie tak potraktowana. Jedyny sposób to przekazanie albo przez wartość albo przez wskaźnik lub referencje z modyfikatorem const. Aby kompilator nie zgłaszał błędu, funkcja Dodaj musi być uprzednio zadeklarowana/zdefiniowana następująco:

float Dodaj(const float * x,const & y)

  {

   return *x + y;

  }

Jak widać oba parametry funkcji Dodaj posiadają modyfikator const, dzięki czemu kompilator ma pewność, że w kodzie Dodaj nie występuje modyfikacja oryginalnych wartości przekazanych do funkcji Obliczania. Gdyby Dodaj nie zawierała modyfikatora const przed parametrami, bylibyśmy zmuszeni przed jej wywołaniem skopiować wartości posXposY do zmiennych tymczasowych i użyć ich jako parametrów aktualnych.

Właściwości "ochronne" const dla parametrów przekazywanych przez wskaźnik lub referencje są nie do przecenienia. Nie mając dostępu do źródeł wszystkich wywoływanych funkcji powinniśmy bądź przykazywać parametry przez wartość, bądź tworzyć kopie w roboczych zmiennych tymczasowych. Przekazanie przez wartość prostych zmiennych wbudowanych jak int, float, double etc., czy małych struktur nie stanowi w zasadzie problemu. Co jednak zrobić z obiektem czy strukturą o długości np. 2 kB? Tworzenie takich obiektów tymczasowych jest jeszcze gorsze niż przekazanie przez ich wartość – bo i tak zajmują miejsce na stosie, a proces kopiowania do zmiennej roboczej czy do przestrzeni parametrów aktualnych jest taki sam. Tu uratować nas może tylko const. Jeśli jednak wywoływana funkcja nie posiada tego modyfikatora przed parametrem, to niestety musimy zamknąć oczy i z bólem przekazać strukturę przez wartość. Kilkaset cykli CPU zostanie zmarnowane, ale taka jest cena nie stosowania słowa kluczowego const!

Jak widać z powyższych przykładów, stosowanie modyfikatora cost:

  • Porządkuje kod.
  • Ułatwia testowanie oraz przyspiesza proces wyszukiwania błędów w kodzie, szczególnie przy analizie złożonych programów.
  • Zwiększa swobodę optymalizatora podczas kompilacji programu.

Dlatego pamiętajmy o dobrej umownej regule pisania kodu:

"Wszystko to co może być const, musi być const".

Copyright © Ebitech Spółka z o.o.