W codziennej pracy zazwyczaj nie wykorzystujemy pełnych możliwości języka. Wynika to z braku takiej potrzeby, a także z niedoskonałości niektórych jego elementów. Założę się, że znajdzie się kilka konstrukcji w języku C, z którymi się nie spotkałeś. W dzisiejszym artykule przeczytasz o jednej z takich ciekawostek istniejącej w języku C, ale rzadko kiedy wykorzystywanej. Standard C11 wprowadza do języka C między innymi wyrażenie _Generic, które pozwala zbliżyć się do przeciążania funkcji dostępnego w C++. Postanowiłem krótko przedstawić proste zastosowania tego wyrażenia, które być może okaże się dla Ciebie przydatne.

Składnia wyrażenia _Generic przypomina trochę znanego wszystkim switch’a. Główna różnica polega na tym, że poszczególne case’y tego switcha nie zależą od wartości argumentu, tylko od jego typu. Przykładowo poniższe wyrażenie _Generic

char c = 'a';
_Generic(c, char: 1, int16_t: 2, default: 3)

zostanie przetworzone do wartości 1, ponieważ zmienna c jest typu char. Jakie daje nam to możliwości? Możemy na przykład do kodu dodać dodatkową uniwersalną warstwę obsługi podobnych do siebie obiektów. Możemy też wykorzystać _Generic do tworzenia łatwiejszego w utrzymaniu, a co za tym idzie bardziej odpornego na ludzkie błędy kodu.

wyzwanie

Załóżmy abstrakcyjny przypadek, w którym musimy napisać funkcję sprawdzającą czy zmienna jest nasycona, czyli czy osiągnęła maksymalną wartość dopuszczalną dla danego typu. Dla zmiennej typu int16_t będzie to liczba 32767 czyli 0x7FFF w zapisie heksadecymalnym.

#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>


static bool isSaturated(int16_t value)
{
    return value == INT16_MAX;
}

int main(void)
{
    int16_t val = 123;
    printf("%d: isSaturated = %u\n", val, isSaturated(val));

    val = 0x7FFF;
    printf("%d: isSaturated = %u\n", val, isSaturated(val));

    return 0;
}

Pisząc funkcje wiemy, że zmienna będzie typu int16_t, więc intuicyjnie wykorzystujemy proste i skuteczne przyrównanie do zdefiniowanej w stdint.h stałej INT16_MAX. Kompilujemy i uruchamiamy.

$ gcc -std=c99 -Wall -Wextra -Wpedantic generic.c && ./a
123: isSaturated = 0
32767: isSaturated = 1

Wszystko działa zgodnie z naszymi oczekiwaniami. Co w przypadku, gdy chcemy zmienić typ zmiennej val na int32_t? Jesteśmy zmuszeni napisać podobną funkcję dla typu in32_t i w przyszłości pilnować, żeby używać odpowiedniej funkcji do sprawdzanego typu?

praktyczne wykorzystanie _Generic

C11 wykorzystując _Generic umożliwia zastosowanie prostszego rozwiązania, z którego teraz skorzystamy. Tworzymy makro IS_SATURATED.

#define IS_SATURATED(a) (a == MAX_RANGE(a))

Jaki otrzymujemy rezultat? Nie określając typu zmiennej możemy skorzystać z makra zwracającego prawdę, gdy zmienna jest nasycona. Magia _Generic ukryta jest pod makrem MAX_RANGE.

#define MAX_RANGE(num) _Generic(num, int8_t:   INT8_MAX,  \
                                     int16_t:  INT16_MAX, \
                                     int32_t:  INT32_MAX, \
                                     int64_t:  INT64_MAX, \
                                     uint8_t:  UINT8_MAX, \
                                     uint16_t: UINT16_MAX,\
                                     uint32_t: UINT32_MAX,\
                                     uint64_t: UINT64_MAX)

Tutaj wykorzystując _Generic stworzyliśmy coś w rodzaju switch’a, który wykrywa typ zmiennej i zwraca maksymalną wartość, jaką ta zmienna może reprezentować. Użycie takiego makra jest banalnie proste.

int main(void)
{
    int8_t a = 1;
    int16_t b = 2;
    uint32_t c = 3;

    printf("a = %d, MAX_RANGE(a) = 0x%X\n", a, MAX_RANGE(a));
    printf("b = %d, MAX_RANGE(b) = 0x%X\n", b, MAX_RANGE(b));
    printf("c = %d, MAX_RANGE(c) = 0x%X\n", c, MAX_RANGE(c));
    return 0;
}

Kompilujemy i uruchamiamy.

$ gcc -std=c11 -Wall -Wextra -Wpedantic generic.c && ./a
a = 1, MAX_RANGE(a) = 0x7F
b = 2, MAX_RANGE(b) = 0x7FFF
c = 3, MAX_RANGE(c) = 0xFFFFFFFF

Wszystko działa tak, jakbyśmy się tego spodziewali. Tak skonstruowane makro moglibyśmy wykorzystać do rozwiązania problemu omawianego w pierwszym artykule z serii Pułapki Języka C: Integer Overflow.

Dodatkowo wyrażenie _Generic obsługuje, podobnie jak switch, przypadek domyślny – default. Działa dokładnie tak, jak Ci się wydaje. Jeżeli typ podanej zmiennej nie pasuje do żadnej z opcji to zwrócona zostanie wartość określona jako default. W przypadku naszego makra MAX_RANGE unikałbym stosowania default, ponieważ dla nieprzewidzianego przez nas typu lepiej otrzymać komunikat o błędzie kompilacji niż niepoprawną wartość. Dla przykładu, jeżeli będziemy chcieli wykonać poniższy kod

float a = 1;
printf("a = %f, MAX_RANGE(a) = %f\n", a, MAX_RANGE(a));

kompilator słusznie zaprotestuje, ponieważ w makrze IS_SATURATED nie przewidzieliśmy maksymalnej wartości dla typu float.

$ gcc -std=c11 -Wall -Wextra -Wpedantic generic.c && ./a
generic.c: In function ‘main’:
generic.c:51:47: error: ‘_Generic’ selector of type ‘float’ is not compatible with any association
     printf("a = %f, MAX_RANGE(a) = %f\n", a, MAX_RANGE(a));
                                                        ^
generic.c:5:33: note: in definition of macro ‘MAX_RANGE’
 #define MAX_RANGE(num) _Generic(num, int8_t:   INT8_MAX,  
                                 ^~~

Wyrażenie _Generic nie daje dokładnie takich samych możliwości jak przeciążanie funkcji (ang. function overloading) w C++. Przede wszystkim różnica polega na tym, że za jego pomocą możemy napisać makro operujące na różnych typach, ale nie funkcję. _Generic posiada szereg ograniczeń i problemów, które zostały szerzej opisane np. tutaj. Osobiście nie widziałem jeszcze zastosowania _Generic w kodzie produkcyjnym i nie wiem czy kiedykolwiek zobaczę. Podejrzewam, że w złożonych projektach, w których przeciążanie funkcji może wnieść znaczną wartość do architektury kodu, zastosowanie znajdzie raczej C++.

Miałeś okazję pisać albo czytać kod, który w ciekawy sposób wykorzystywał _Generic? Podziel się przykładem w komentarzu.