Stałość fizyczna i logiczna w C++

Michał ‘mina86’ Nazarewicz | 21 marca 2008

Chciałbym zaprezentować pewien tekst, który przygotowałem na jeden z przedmiotów na studiach. Traktuje on o modyfikatorze const i mutable oraz określa pojęcie stałości logicznej czyli niezmienności zachowania obiektu, gdy patrzymy z zewnątrz. Może komuś się to przyda. Czekam również na wszelkie komentarze.

Deklarowanie stałych

Stałość fizyczna występuje, gdy w momencie kompilacji określamy przy pomocy modyfikatora const, że jakiejś wartości nie należy zmieniać.

const int foo = 42;  /* Definicja "zmiennej" foo będącej stałą
                        liczbą całkowitą i inicjacja jej wartością 42. */
foo = 666;           /* Błąd kompilacji */

Modyfikator ten odnosi się do typu, co ma szczególne znaczenie przy wskaźnikach:

int bar, baz;

const int *ptr1 = &bar;    /* Wskaźnik na stały int. */
ptr1 = &baz;               /* Poprawne. */
*ptr1 = 666;               /* Błąd kompilacji. */

int *const ptr2 = &bar;    /* Stały wskaźnik na int. */
ptr2 = &baz;               /* Błąd kompilacji. */
*ptr2 = 666;               /* Poprawne. */

const int *const ptr3 = &bar; /* Stały wskaźnik na stały int. */
ptr3 = &baz;               /* Błąd kompilacji. */
*ptr3 = 666;               /* Błąd kompilacji. */

Rzecz jasna mamy do dyspozycji także większe "zagłębienia" wskaźników:

const int **ptr4;          /* Wskaźnik na wskaźnik na stały int. */
int *const *ptr5;          /* Wskaźnik na stały wskaźnik na int. */
int **const ptr6;          /* Stały wskaźnik na wskaźnik na int. */

const int *const *ptr7;    /* Wskaźnik na stały wskaźnik na stały int. */
const int **const ptr8;    /* Stały wskaźnik na wskaźnik na stały int. */
int *const *const ptr9;    /* Stały wskaźnik na stały wskaźnik na int. */

const int *const *const ptrA;  /* Stały wskaźnik na stały wskaźnik
                                  na stały int. */

Możnaby tak w nieskończoność, szczególnie, że mamy jeszcze wskaźniki do funkcji… No ale, powstrzymam się. ;) Przypominam, że w C oraz C++ typy czyta się "od prawej do lewej".

Stałość a modyfikowalność

Należy zwrócić uwagę, iż z faktu, że wskaźnik jest stałego typ nie wynika, iż wskazywana wartość nie może się zmienić. Jest to częste, a niesłuszne domniemanie. Najprostrzym przykładem może być kod:

int foo = 42;
int *bar = &foo;
const int *baz = &bar;
/* baz ma wartość 42 */
*bar = 666;
/* baz ma wartość 666 */

(Utrudnia to optymalizację różnych funkcji, które przyjmują dwa wskaźniki do tego samego typu. Jeżeli tylko jeden ze wskaźników wskazuje na typ stały kompilator nie wie, czy ten drugi nie wskazuje na (przynajmniej w części) ten sam obszar. Aby temu zaradzić, w C99 (C++ tego nie ma) zostało dodane słówko restrict, które odnosi się do wskaźników i określa, że wskazywana wartość nie może być osiągalna przez inne wskaźniki.)

Różnie bywa z wartościami zadeklarowanymi jako stałe. Mogą one zostać zapisane w niemodyfikowalenj przestrzeni pamięci i próba zmiany ich wartości może spowodować coś pokroju segmentation fault, ale równie dobrze mogą być umieszczone w zwykłej przestrzeni i wówczas sztuczka z rzutowaniem pozwoli na ich zmianę:

const int foo = 42;
*const_cast<int*>(&foo) = 666;     /* niezdefiniowane zachowanie */

const_cast<char*>("bar")[2] = 'z'; /* niezdefiniowane zachowanie */

Stałość argumentów funkcji

Deklarowanie argumentów (chodzi o sam argument, a nie ewentualne wskazywane typy, a więc referencje odpadają z rozważań) funkcji jako stałych nie wpływa na zewnętrzne zachowanie funkcji, gdyż i tak argumenty przekazywane są przez wartość (o ile nie jest to referencja). Można to jednak stosować, aby zapobiec zmianie wartości argumentów, co jest zalecaną przez niektórych praktyką, np.:

int add(const int a, const int b) {
    a += b;        /* Błąd kompilacji. */
    return a + b;  /* Poprawne. */
}

Deklarowanie typów wskazywanych przez funkcje jako stałe ma za to duży wpływ na zachowanie zewnętrzne programu i należy je stosować wszędzie tam, gdzie jest to możliwe. Tzn. jeżeli jakaś funkcja przyjmuje wskaźnik lub referencję na argument, którego nie zamierza modyfikować powinna wskazywany typ zadeklarować jako stały, np.:

int sum(unsigned n, const int *nums) {
    int ret = 0;
    while (n--) {
        ret += *num++;
    }
    return ret;
}

static const int nums[] = { /* … */ };
/* … */
sum(sizeof nums / sizeof *nums, nums);   /* gdyby w prototypie funkcja
       miała typ `int*`, a nie `const int*` ta linijka spowodowałaby
       błąd kompilacji. */

Ponadto, często o wiele lepiej przekazywać argument przez stałą referencję zamiast przez wartość, gdyż nie wymaga to kopiowania całego obiektu, np.:

int foo(const std::vector<int> &vec) {
    /* Rób coś na wektorze */
}

/* versus */

int foo(std::vector<int> vec) {
    /* Rób coś na wektorze */
}

W obu przypadkach, wołając funkcję, mamy pewność, iż wektor przekazany jako argument nie zostanie zmodyfikowany (mowa o zachowaniu na zewnątrz funkcji), ale przekazywanie wektora przez wartość jest po prostu stratą czasu. Dotyczy to również innych mniejszych i większych obiektów. Szczególnie, gdy definiujemy jakiś szablon należy stosować mechanizm przekazywania argumentów przez stałą referencję zamiast przez wartość, gdyż nie wiemy z jakim typem będziemy mieli do czynienia. W szczególności klasa może nie mieć (publicznego) konstruktora kopiującego.

Rzutowanie

Jak można się domyślić, rzutowanie z typu bez modyfikatora const na tym z takim modyfikatorem jest automatyczne, np.:

int foo = 42;
const int *bar = &foo;

Rzutowanie w drugą stronę nie jest już automatyczne i wymaga zastosowanie operatora rzutowania:

const int foo = 42;
int *bar = (int*)&foo;              /* styl C */
int *baz = const_cast<int*>(&foo);  /* styl C++ */

Generalnie zalecany jest styl C++, gdyż w ten sposób jesteśmy pewni, że rzutowanie zmieni jedynie stałość typu. Przykładowo, gdybyśmy zmienili typ zmiennej foo, a zapomnieli zmienić typy w operatorach rzutowania kompilator bez żadnych ostrzeżeń skompilowałby rzutowanie w stylu C, ale zgłosiłby błąd przy rzutowaniu w stylu C++, gdyż zmiana dotyczy nie tylko stałości typu:

const long foo = 42;
int *bar = (int*)&foo;              /* Skompiluje się. */
int *baz = const_cast<int*>(&foo);  /* Błąd kompilacji. */

Pewien wyjątek stanowią literały ciągów znaków, który wywodzi się z czasów, gdy w języku C nie było słówka const. Zasadniczo literały ciągów znaków są typu const char[] jednak, aby nie psuć tysięcy istniejących programów, przypisanie literału do zmiennej typu char* jest poprawne. Nie oznacza to jednak, iż literały takie można modyfikować! Następująca instrukcja powoduje niezdefiniowane zachowanie: char *foo = "foo"; foo[0] = 'F'; (problem ten został już poruszony przy omawianiu modyfikowalności)

Kolejnym aspektem, który może wydać się dziwny, jest fakt, iż konwersja Foo** do const Foo** nie jest automatyczna. Można powiedzieć, że stałość musi być dodana wszędzie po prawej stronie (za wyjątkiem samej zmiennej) i konwersja Foo** do const Foo* const * jest już dozwolona:

void bar(const Foo **arr);
void baz(const Foo *const *arr);

int main(void) {
    Foo **arr;
    /* … */
    bar(arr); /* Błąd kompilacji. */
    baz(arr); /* Poprawny kod. */
    /* … */
}

Aby zrozumieć czemu tak jest należy przeanalizować poniższy kod:

const int x = 42;
int *p;
const int **pp = &p;  /* konwersja `int**` do `const int**`*/
*pp = &x;             /* `*pp` jest typu `const int*`, więc można mu
                         przypisać adres zmiennej `x`. */
                      /* w tym momencie `p` wskazuje na `x` (gdyż `pp`
                         wskazywało na `p`. */
*p = 666;             /* `x` jest modyfikowane! */

Stałość w strukturach

Stałe struktury (klasy) nie umożliwiają zmiany pól w ich wnętrzu. Przykładowo, poniższy kod się nie kompiluje:

struct Foo {
    int bar;
};

const Foo baz = { 42 };
baz.bar = 666;

Jednakże, jeżeli elementem struktury (klasy) jest wskaźnik jedynie on sam staje się stały, ale wartość wskazywana nie, np.:

struct Foo {
    int *bar;
};

static int qux = 42, quux = 042;
const Foo baz { &qux };
*baz.bar = 666;       /* Poprawne. */
baz.bar = &quux;      /* Błąd kompilacji. */

Jest to problem stałości logicznej, o której poniżej.

Niestatyczne metody klas również mogą być zadeklarowane jako stałe. Metody takie nie mogą modyfikować pól klasy (chyba, że posiadają modyfikator mutable, o którym niżej) ani wołać innych metod, które nie są zadeklarowane jako stałe.

struct Foo {
    int get()       { return bar; }             /* [1] */
    int get() const { return bar; }             /* [2] */

    int sum(int v)       { return get() + v; }  /* [3]; woła [1] */
    int sum(int v) const { return get() + v; }  /* [4]; woła [2] */

    void set(int v)       { bar = v; }          /* [5] */
    void set(int v) const { bar = v; }          /* niepoprawne */

    void add(int v)       { set(sum(v)); }      /* woła [3] i [5] */
    void add(int v) const { set(sum(v)); }      /* niepoprawne, woła
                              [4], ale [5] nie jest metodą stałą. */

private:
    int bar;
};

Czasami bywa tak, że metoda ma swoją wersję stałą i niestałą, które robią identyczną rzecz, ale np. jedna zwraca wskaźnik do stałego obiektu, a druga do niestałego obiektu. Aby nie musieć pisać dwa razy tego samego kodu można zastosować rzutowanie, np.:

struct Foo {
    /* … */
    const int *find(int arg) const;
    int *find(int arg) {
        return const_cast<int*>(const_cast<const Foo*>(this)->find(arg));
    }
    /* … */
};

lub

struct Foo {
    /* … */
    const int *find(int arg) const {
        return const_cast<Foo*>(this)->find(arg);
    }
    int *find(int arg);
    /* … */
};

Rzutowanie słówka this wymusza wołanie stałej lub niestałej wersji danej metody. Bez niego mielibyśmy do czynienia z niekończącą się rekurencją.

C++ wprowadza jeszcze jedno słowo kluczowe -- mutable, które oznacza, że dany element struktury może być modyfikowany, w tej klasie nawet jeżeli jest ona stała. Przykładowo:

struct Foo {
    int bar;
    mutable int baz;

    void mutate() const {
        bar = 042; /* Błąd kompilacji. */
        baz =  42; /* Kod poprawny. */
    }
};

const Foo foo = { 42, 042 };
foo.bar = 666;  /* Błąd kompilacji. */
foo.baz = 666;  /* Kod poprawny. */

Mechanizm ten powinien być stosowany tylko i wyłącznie dla pól, które nie wpływają na zewnętrzny wygląd i zachowanie klasy (patrz stałość logiczna). Przykładowo, można zaimplementować cache, w której przechowywane by były ostatnie wyniki jakichś zapytań czy wyszukiwań:

struct Foo {

    /* … */

    const int *find(int arg) const {
        if (last_arg == arg) {
            return last_found;
        } else {
            int *found;
            /* Wyszukaj i ustaw `found` odpowiednio. */
            last_arg = arg;
            return last_found = found;
        }
    }

    /* … */

private:
    mutable int *last_found;
    mutable int last_arg;
};

Alternatywą dla słówka mutable byłoby rzutowanie, jednak w różnych przypadkach może ono spowodować niezdefiniowane zachowanie, co zostało już omówione.

Stałość logiczna

Stałość logiczna to oczekiwanie, że zewnętrzne zachowanie i wygląd jakiegoś obiektu nie ulegnie zmianie. Dla przykładu weźmy strukturę do przechowywania liczb zespolonych:

struct Complex {
    double re, im;
};

W tym przypadku zadeklarowanie jakiejś zmiennej jako stałej gwarantuje nam stałość logiczną, np.:

double abs(const Complex &c) {
    return hypot(c.re, c.im);
}

Jednak w bardzo rzadkich przypadkach stałość fizyczna może być nadgorliwa, np. jeżeli cacheujemy wyniki:

struct Complex {
    double re, im, abs;
    bool abs_valid;
}

double abs(const Complex &c) {
    if (c.abs_valid) {
        return c.abs;
    } else {
        c.abs_valid = true;
        return c.abs = hypot(c.re, c.im);
    }
}

Powyższy kod oczywiście się nie skompiluje, ale z pomocą przychodzi nam już wcześniej opisane słówko mutable.

O wiele częściej stałość fizyczna jest za mało gorliwa. Przykładowo, jeżeli mamy strukturę przechowującą ciągi znaków, to spodziewamy się, iż po zadeklarowaniu zmiennej jako stała struktura nie będzie możliwości zmieniać samego napisu, ale niestety tak nie jest:

struct String {
    char *data;
    unsigned length;
};

void modify(const String &s) {
    s.data[0] = 'A';
}

W takich przypadkach należy odpowiednio obudowywać takie klasy dodając im przeciążone akcesory istniejące zarówno w wersji jako metoda stała oraz w wersji jako zwykła metoda.

struct String {
    char *getData() { return data; }
    const char *getData() const { return data; }
    unsigned getLength() const { return length; }

private:
  char *data;
  unsigned length;
};

Oczywiście nadal wewnątrz stałej metody obiekty wskazywane przez pole data mogą być modyfikowane i dlatego cała odpowiedzialność, za utrzymanie stałości logicznej spada na programiście, który implementuje daną klasę.