Zmienne programu są w pamięci reprezentowane jako pewien ciąg bitów. Dla komputera nie ma znaczenia, czy ten ciąg to liczba, string, struktura, czy cokolwiek innego. Pisząc programy definiujemy typy zmiennych i to na te typy rzutowane są wartości bitowe.

Języki wyższego poziomu jak np. C#, czy Python oddzielają tą implementację za pewną warstwą abstrakcji. Twórcy tych języków doszli do wniosku, że program nie powinien polegać na konkretnej reprezentacji bitowej. Możemy oczywiście za pomocą odpowiednich funkcji uzyskać dostęp do takiej bitowej reprezentacji, ale czynność ta celowo została utrudniona.

C jest jednak starszym językiem wyznającym zasadę, że programista wie co robi. Dlatego w C mamy łatwy dostęp do reprezentacji bitowej. Możemy używać wskaźników, czy rzutować typy na inne. Efektem jest większa wydajność wynikowych programów za cenę wolniejszego developmentu. Sprawdzanie błędów w C jest bardzo ubogie i nawet doświadczony programista może łatwo strzelić sobie w stopę.

W tym wpisie pokażę kilka potencjalnie niebezpiecznych fragmentów kodu oraz omówię jak radzi sobie z nimi standard MISRA C oraz jak to rozwiązano w C++.

Typy i wskaźniki w C

Wskaźnik w C przechowuje informacje o adresie w pamięci i o typie (ta informacja jest wykorzystywana np. przy inkrementacji adresu – dodawany jest wtedy sizeof(type), a nie 1). Nie ma żadnego problemu, żeby wskazywany adres tak naprawdę przechowywał zmienną innego typu.

#include <stdio.h>

int main(void)
{
    float f;
    int *ptr_int;

    f = 1.0f;
    ptr_int = &f;

    printf("%d\n", *ptr_int);

    return 0;
}

Output: 1065353216

Kompilator zgłosi warning o podejrzanym rzutowaniu, ale nam tego nie zabroni.

main.c: In function 'main':
 main.c:7:13: warning: assignment from incompatible pointer type [-Wincompatible-pointer-types]
 ptr_int = &f;

Warning oczywiście możemy obejść rzutując &f:

ptr_int = (int *)&f;

Otwiera nam to wiele ciekawych możliwości. Możemy na przykład wykorzystać rzutowanie do optymalizacji obliczeń, jak w słynnym kodzie z Quake 3:

float Q_rsqrt(float number)
{
    long i;
    float x2, y;
    const float threehalfs = 1.5F;
    
    x2 = number * 0.5F;
    y = number;
    i = *(long *)&y;                         // evil floating point bit level hacking
    i = 0x5f3759df - (i >> 1);               // what the fuck?
    y = *(float *) &i;
    y = y * (threehalfs - (x2 * y * y));     // 1st iteration
//    y = y * (threehalfs - (x2 * y * y));     // 2nd iteration, this can be removed

    return y;
}

Jednak równie dobrze możemy zrobić coś naprawdę dziwnego np:

typedef void (*fun_ptr)(int a);

int main() {
   float f = 123.4f;
   ((fun_ptr)&f)(1);
}

W powyższym kodzie rzutujemy liczbę float na wskaźnik na funkcję i go wywołujemy. Kod najprawdopodobniej zakończy się runtime errorem, jednak sam kompilator nam tego nie zabroni. W zdecydowanej większości przypadków nie będzie to pożądane działanie, jednak ten jeden raz na milion może się przydać. Ten konkretny trick (co prawda rzutujemy uint32_t a nie float) stosuje się na przykład pisząc bootloader, kiedy chcemy skoczyć do właściwego programu.

Jeśli decydujemy się na zastosowanie tego typu hacków, na pewno musimy opatrzyć je odpowiednim komentarzem. Po jakimś czasie prawdopodobnie sami nie będziemy pamiętać, czy pisząc ten kod zdawaliśmy sobie sprawę, że rzutujemy na niekompatybilny typ i czy na pewno tak to miało wyglądać.

A co na to MISRA C?

Jak nietrudno się domyślić standard MISRA C uznaje rzutowanie wskaźników za potencjalnie niebezpiecznie i go zakazuje. Możliwe jest jedynie rzutowanie do void pointera. Prawdopodobnie po to, aby  można było korzystać np. z memcpy. Nie jest to wielkie zaskoczenie – jak chcemy, aby aplikacja była bezpieczna, nie należy stosować takich tricków.

Rozwiązanie z C++

W C++ problem rzutowania został fajnie rozwiązany. Wprowadzono różne rodzaje rzutowań:

  • static_cast – używamy do konwersji między kompatybilnymi typami np. enum -> int, float -> int, ale float * -> int * już nie. Ogólna zasada mówi, że static cast używamy jeżeli w odwrotnym kierunku kompilator wykonałby rzutowanie niejawnie.
  • const_cast – używamy, aby dodać lub usunąć modyfikator const z pointera.
  • dynamic_cast – używamy do rzutowania między klasami, które po sobie dziedziczą.
  • reinterpret_cast – używamy do rzutowania między jakimikolwiek typami. Kompilator nie sprawdza, czy rzutowanie ma sens. Można powiedzieć, że jest to odpowiednik rzutowania z C – typy nie są ze sobą powiązane, ale programista wie co robi.

Więcej informacji o rodzajach rzutowań w C++ można znaleźć tutaj.

Dzięki zastosowaniu takiego podziału programista może łatwo zasygnalizować, że np. rzutuje na kompatybilny typ i stosuje wtedy static_cast. Jeżeli na skutek późniejszych modyfikacji typ się zmieni na niekompatybilny, kompilator wyrzuci błąd. Natomiast stosując reinterpret_cast dajemy jasno do zrozumienia, że z premedytacją rzutujemy na zupełnie inny typ.