Zanim przejdziemy do omawiania tematu, czyli przecinka w języku C, mam dla Ciebie króciutki programik do analizy. Spróbuj wydedukować, jaki będzie wynik jego działania:
#include <stdio.h>
#define PI (3,1415)
int main()
{
// Calculates the area of circle
float area, radius;
radius = 1;
area = PI * radius * radius;
printf("area = %f", area);
return 0;
}
Poprawna odpowiedź to:
a) program nie skompiluje się,
b) program wypisze „area = 3.141500”,
c) program wypisze „area = 1415.000000”,
d) program wypisze „area = 3.000000”.
Oczywiście, co do potwierdzenia działania, najprościej skopiować podany program i go skompilować. Zaskoczeni wynikiem?
Przyczynę, dlaczego program działa tak, a nie inaczej, omówimy sobie na końcu artykułu. A teraz zapraszam do lektury.
Prawdopodobnie wiele osób w tym momencie pomyśli, że temat działania przecinka (,) w języku C jest tak trywialny i prosty, że nie za bardzo jest, co omawiać, a już na pewno nie jest to temat na artykuł. Cóż, postaram się pokazać, iż jest z goła inaczej i przecinek potrafi być skompilowany w nieoczekiwany dla nas sposób, przysparzając nam bólu głowy.
Rola przecinka w języku C
Przecinek w języku C może wystąpić w dwóch kontekstach: jako separator oraz jako operator. Zdecydowana większość programistów zna i używa przecinka jedynie jako separatora. Niewielki odsetek osób wie, że może on także wystąpić jako operator. Już sam brak wiedzy o tym, zwiększa ryzyko błędów czy pomyłek. Omówmy te dwa przypadki bardziej szczegółowo.
Przecinek jako separator
Rola przecinka jako separatora jest podstawową funkcją w języku C i jest powszechnie znana oraz wykorzystywana. Zasady jego użycia poznajemy już na samym początku nauki programowania w języku C. Jak wskazuje nazwa, jego zadaniem jest odseparowanie:
- identyfikatorów,
- parametrów funkcji,
- makr,
- nazw zmiennych w deklaracjach itp.
Przykłady użycia przecinka jako separatora
Spójrzmy na przykłady, zwracając uwagę na umieszczone w kodzie przecinki:
//Definicja makra z dwoma argumentami
#define min(X, Y) ((X) < (Y) ? (X) : (Y))
//Deklaracja dwóch zmiennych typu int i ich inicjalizacja
int a = 1, b = 2;
//Deklaracja funkcji o dwóch parametrach typu int
void func(int x, int y);
//Deklaracja enumeratora
enum week{Mon, Tue, Wed, Thu, Fri, Sat, Sun};
//Deklaracja i inicjalizacja tablicy int-ów
int arr[5] = { 1, 2, 3, 4, 5 };
//Wywołanie funkcji z dwoma parametrami
func(1, 2);
Użycie przecinka w tym kontekście jest dość intuicyjne. Są jednak przypadki, gdzie możemy popełnić błąd. Spróbujmy zadeklarować dwa wskaźniki p1 oraz p2:
int* p1, p2;
Wbrew temu, co może się wydawać na pierwszy rzut oka, „p2” nie będzie wskaźnikiem na int, a po prostu zmienną typu int. Jedynie „p1” będzie wskaźnikiem.
Sposoby zadeklarowania dwóch wskaźników
Aby prawidłowo zadeklarować dwa wskaźniki, powinniśmy to zrobić na jeden z następujących sposobów:
int* p1;
int* p2;
int *p3, *p4;
typedef char * char_ptr_t;
char_ptr_t p5, p6;
Linie 1 i 2 zawierają oczywiste i czytelne rozwiązanie problemu, jednak przy dużej ilości wskaźników do deklaracji, będzie zajmować wiele linii, wydłużając kod.
W linii 4 mamy bardziej skondensowane rozwiązanie. W linii 6 i 7 deklarujemy własny typ wskaźnikowy, deklarując p5 i p6 jako takie właśnie wskaźniki. W tym rozwiązaniu pozbywamy się problemu gdzie i ile postawić gwiazdek, ale patrząc tylko na linię 7, nie jest oczywiste, iż mamy tutaj do czynienia ze wskaźnikami.
Przecinek jako operator
Jak już zostało to wspomniane, przecinek może także występować jako operator. Jest to mniej znana postać, którą dość rzadko możemy spotkać w kodzie źródłowym, ale którą warto opisać.
Składnia:
expression1 , expression2
Operator przecinka: wylicza pierwszy operand (expression1) i odrzuca jego wynik, następnie wylicza drugi operand (expression2) i zwraca jego wartość.
Operator przecinka oddziela operacje, analogicznie do średnika, który jest terminatorem instrukcji:
a, b; //sekwencja wyrażeń, podobna do:
A; B; //sekwencja instrukcji
Bardzo ważna jest także informacja, iż operator przecinka ma najniższy priorytet spośród wszystkich operatorów C i działa jako punkt sekwencyjny.
Aby lepiej zrozumieć działanie operatora przecinkowego, spójrzmy na kilka przykładów i dokonajmy ich analizy.
Deklaracja zmiennej
int x = 1, 2;
Błąd kompilacji (error: expected identifier). Spójrzmy, jak zinterpretuje to kompilator. Użyjemy nawiasów, aby ułatwić sobie wizualizację, co będzie wykonane najpierw:
int (x = 1), 2; //najpierw zostanie zadeklarowana zmienna x i zainicjonowana wartością „1”, następnie:
int 2; // tutaj mamy błąd.
Taka interpretacja wynika z wyższości priorytetu znaku przypisania „=” nad operatorem przecinka „,”.
Poprawna deklaracja zmiennej
int y = (1, 2);
Ten kod się skompiluje. Za pomocą nawiasów wymuszamy wykonanie operatora przecinkowego przed przypisaniem. 1 jest odrzucane, a z operacji w nawiasie jest zwracane 2 i ostatecznie: y=2.
Przypisanie wartości
int x;
x = 5, 6;
I tutaj znów nieintuicyjnie x będzie miało wartość 5. Spójrzmy na interpretację kompilatora (ponownie użyjemy nawiasów do wizualizacji kolejności operacji):
int x;
(x = 5), 6;
Czyli najpierw przypisanie 5 do x. Następnie wartość 6 nie jest nigdzie przypisywana.
Przypisanie wartości + nawiasy
int x;
x = (3, 4);
Nawiasami wymuszamy wykonanie operatora przecinka: 3 jest odrzucane, 4 jest zwracane i przypisane do zmiennej x.
Obliczenie i przypisanie wartości
int x;
int y = 1;
x = y += 5, y + y;
I spójrzmy, jak zostanie to skompilowane, zaznaczając nawiasami kolejność wykonywanych operacji:
(x = (y += 5) ), (y + y);
Kolejny raz daje o sobie znać najniższy priorytet operatora przecinkowego: najpierw y zostanie zwiększane o 5, wynik zostanie przypisany do zmiennej x. Następnie obliczony zostanie wynik sumy y + y, który to rezultat będzie porzucony.
tak więc ostatecznie x == 6
Obliczenie i przypisanie wartości – drugi przykład
Przykład podobny do poprzedniego, ale wymusimy nawiasami wykonanie operacji dla pożądanej kolejności:
int x;
int y = 1;
x = ( y += 5, y + y );
Teraz kolejność operacji będzie następująca:
y zwiększone o 5,
obliczona suma y + y, wynik sumy zapisany do x
ostatecznie x == 12
W tym przykładzie warto zwrócić uwagę, że mamy gwarantowaną kolejność wykonywania operacji: najpierw jest liczone y += 5, i tak zmodyfikowana wartość y jest wykorzystana do obliczania sumy y + y. Gwarancję taką daje właśnie przecinek, który jest punktem sekwencyjnym.
Wołanie funkcji
x = (Func1(), Func2(), Func3());
Zawołane zostaną kolejno funkcje (w tej gwarantowanej kolejności): Func1, Func2 i odrzucona zwracana przez nie wartość. Ostatecznie zawołana zostanie funkcja Func3 i wynik zostanie przypisany do zmiennej „x”.
Operator warunkowy (ternary operator)
y = (a < b) ? f1(), f2() : f3(), f4();
Jeśli warunek a<b zostanie spełniony zostanie zawołana funkcja “f1()”, następnie zawołana funkcja “f2()” i wartość przez nią zwrócona przypisana do zmiennej y
W przeciwnym razie (gdy a>=b) analogicznie zawołana zostanie funkcja f3, następnie funkcja f4() i zwrócana przez nią wartość przypisana do zmiennej y.
Zwracanie wartości
int x, y;
int fun(void){
return x=10, y=20;
}
W linii 4 do zmiennej x zostanie przypisana wartość 10. Następnie do zmiennej y przypisane zostanie wartość 20 i ta właśnie wartość zostanie zwrócona przez funkcję “fun()”.
Przecinek jako separator oraz operator – podsumowanie
Jak widać na podanych przykładach, działanie przecinka jako operatora potrafi być nieintuicyjne i nieczytelne ze względu na odrzucanie wyniku pierwszej operacji oraz najniższego priorytetu wykonania.
Jego użycie ma sens, gdy pierwszy operand powoduje tzw. efekt uboczny (side effect), który musi nastąpić przed drugim operatorem. Ponieważ przecinek może być separatorem lub operatorem, zależnie od kontekstu w jakim się znajduje w kodzie, dodatkowo powoduje zwiększenie nieczytelności kodu i zwiększa prawdopodobieństwo popełnienia błędu.
Nie bez powodu często standardy kodowania zabraniają lub przynajmniej rekomendują nieużywanie przecinka jako operatora. Także bardzo rzadko można spotkać przecinek w postaci operatora w kodzie produkcyjnym.
Czy to się w ogóle do czegoś przydaje?
Czy, pomimo nieintuicyjnego działania, uda nam się znaleźć jakieś praktyczne zastosowanie operatora przecinkowego?
Cechą, która możemy postarać się wykorzystać, jest gwarancja kolejności wykonywania (punkt sekwencyjny). Odpowiednio wykorzystany może nam zaowocować bardziej zwięzłą i czytelną konstrukcją kodu.
Spójrzmy na poniższe przykłady.
Odczyt danych i sprawdzenie warunku dla pętli while
Some_type *d = &some_data;
while(read_data(d), d->len > 17)
{
//do stuff
}
W tym przykładzie skorzystamy z operatora przecinkowego, aby umieścić blisko siebie kod pobierający dane potrzebne do sprawdzenia warunku wykonania pętli z kodem samego sprawdzenia warunku.
Korzystając z cechy, iż przecinek jest punktem sekwencyjnym, gwarantujemy, że read_data(d) zostanie zawsze wykonane przed sprawdzeniem warunku d->len > 17.
Bardziej złożone pętle for
int i, j;
for(i=0, j=20; i<j; i++, j--){
//do stuff
}
Podobnie jak w przykładzie z pętlą while, w przypadku pętli for możemy skorzystać z operatora przecinka, aby w bardziej spójny i elegancki sposób zainicjalizować zmienne przed wykonaniem pętli (i=0, j=20) oraz zmodyfikować „liczniki” pętli po każdym jej wykonaniu (i++, j–).
Zwracanie błędu
err_type last_error = NO_ERROR;
int fun(void){
//code
if (failed){
return last_error = ERROR_CODE, -1;
}
}
W tym przykładzie funkcja zwracając kod błędu (-1), jednocześnie ustawia zmienną globalną last_error na żądaną wartość.
Podsumowanie
Wracając do pytania z samego początku artykułu, jestem pewien, że teraz już bez problemu wskażecie prawidłową odpowiedź (c) i znajdziecie błąd w linii 2 – przecinek zamiast kropki.
Z błędem tego typu spotkałem się wiele lat temu w czasie analizy nieprawidłowego działania programu. Oczywiście program był dużo bardziej złożony i znalezienie przyczyny problemu nie było tak trywialne.
Błąd należał niestety do kategorii tych „wrednych” – raz że trudno wzrokowo go znaleźć, bo kropka od przecinka różni się niewiele, to dodatkowo program się kompiluje. Jeśli pomyłka jest typu 1.0001 błędnie zapisane jako 1,00001, to może się okazać, że dla wielu przypadków testowych program będzie działał prawidłowo.
Pozostaje pytanie: Czy używać przecinka jako operatora? Jak zwykle odpowiedź brzmi: To zależy.
I oczywiście zależy to od was i waszych preferencji.
W mojej opinii używania operatora przecinkowego należy unikać, przede wszystkim dlatego, iż wiele osób go nie zna i nie rozumie zasad jego działania. Jeśli ktoś będzie poprawiać lub modyfikować program, w którym użyto przecinka jako operatora, zapewne przysporzy mu to wielu problemów, a może również spowodować powstanie większej ilości błędów.
Zachęcam was do podzielenia się w komentarzach swoją opinią w tym temacie. A może znacie jakieś ciekawe konstrukcje z wykorzystaniem przecinka?
Warto dodać, że wynik programu zależy od ustawień kompilatora. Kompilator powinien pokazać warning : left-hand operand of comma expression has no effect [-Wunused-value] i jeśli ustawimy flagę -Werror (dla gcc) to ten warning stanie się błędem. Wtedy program się nie skompiluje.
Niestety nie wszystko złoto co się świeci.
Sporo zależy od kompilatora (!)
Tak samo jak typ 'single’ o którym mieliśmy okazję rozmawiać osobiście.
Do tej pory wspominam to jako anegdotkę 😁
Pozdrawiam serdecznie mojego byłego następcę w firmie P. 😃