Skrajnie niepotrzebne skrajne przypadki.
Standard C++17 składa się z prawie tysiąca stron A4. Prawie tysiąc stron, omawiających najciemniejsze zakamarki języka. Wszelkiego rodzaju niuanse, wyjątki i rzeczy, którymi na co dzień nie warto się przejmować. Spróbujemy przyjrzeć się kilku takim przypadkom, których miejmy nadzieję nigdy nie spotkamy w praktyce.
Nieszczęsna kompatybilność wsteczna
Zgadza się, nieszczęsna! Komisja standaryzacyjna nie lubi zmieniać rzeczy, które łamią kompatybilność wsteczną. Są niewielkie wyjątki - jak na przykład usunięcie niebezpiecznego typu std::auto_ptr
, czy też usunięcie trigraphs
. Niestety, są jeszcze rzeczy pamiętające początki języka, które w C++ są do dzisiaj.
Ciężko sobie wybrazić klawiaturę, która nie posiada pełnego zestawu znaków. Brak znaku #
? Albo nawiasów klamrowych? Dawniej nie wszystkie klawiatury posiadały pełny zestaw znaków zgodny ze standardem ISO 646. I to z myślą o programistach nieposiadających takiej klawiatury powstał twór o nazwie digraphs
. Popatrzmy na poniższy fragment kodu:
int main() {
int a[] = <%1%>;
return a<:0:>;
}
Na pierwszy rzut oka - niepoprawna składnia. Ale wklejamy kod do godbolta, i okazuje się że program jest całkowicie poprawny. Sprawdźcie sami: godbolt.org/z/S9L-RQ!
Kod jest poprawny, dlatego że odpowiednie pary znaków mają inną reprezantację. Wszystko opisane jest w standardzie:
Alternative | Primary |
---|---|
<% | { |
%> | } |
<: | [ |
:> | ] |
%: | # |
%:%: | ## |
Powyższy kod, po podstawieniu dwuznaków na znaki podstawowe, wygląda więc następująco:
int main() {
int a[] = {1};
return a[0];
}
I od razu widać, że program zwróci 1
.
To nie jest tak jak myślisz
Nie tylko dwuznaki na pierwszy rzut oka wyglądają jak błąd. Zobaczmy na poniższy przykład:
#include <iostream>
int main() {
std::cout << 1["ABC"];
}
I znowu - przecież nie da się indeksować stałych! Stałe nie mają również przeciążonego operatora []
. Niemożliwe, żeby kod się skompilował.
A jednak - znowu wklejamy kod do przeglądarki, i oczom ukazuje się… B
Żadnego błędu kompilacji. Sięgamy do standardu, czytamy od deski do deski i… jest!
(…) The expression E1[E2] is identical (by definition) to *((E1)+(E2)) (…)
Powyższe wyrażenie to nic innego jak:
*(1+"ABC")
Operator dodawania jest przemienny, więc możemy to wyrażenie zapisać jako:
*("ABC"+1)
"ABC"
to nic innego jak const char*
, zatem mamy tutaj zwykłą arytmetykę wskaźników. Nasze wyrażenie to tak naprawdę:
"ABC"[1]
czyli… B
.
Kod bardzo generyczny
Wiele rzeczy, które wydają nam się na pierwszy rzut oka dziwne, mają swoje uzasadnienie. Są w standardzie dlatego, że ktoś je zaproponował i miał ku temu racjonalny powód. Przyjrzyjmy się ciut bliżej destruktorowi. Już samo wywołanie go jak zwykłą metodę, bez słowa kluczowego delete
wygląda…. osobliwie:
struct Foo {};
void clean(Foo* f) { // bad design, but just for ilustration
f->~Foo(); // we don't want to free the memory
}
Na co dzień raczej nie chcemy czegoś takiego robić, ale teoretycznie jest to możliwe.
Jeszcze dziwniejsze jest wywołanie takiego destruktora na typie prostym. Jeśli chcielibyśmy wywołać destruktor na przykład typu int
:
void clean(int* i) {
i->~int(); // compilation error: expected identifier before `int`
}
Powyższy kod się nie skompiluje, ponieważ jest niepoprawny składniowo. Jeśli jednak zrobimy sobie alias
dla typu int
, błędu już nie będzie:
using MyInt = int;
void clean(MyInt* i) {
i->~MyInt(); // OK
}
I po co to wszystko? Okazuje się, że tworząc własny kontener, w którym sami dbamy o pamięć (np. używamy niestandardowego allocatora), możemy bezpiecznie wyczyścić zawartość kontenera:
template<typename T>
struct C {
// ...
~C() {
for(size_t i = 0; i < elements_; ++i)
container_[i].~T();
}
};
Nawet, jeśli ktoś stworzy nasz kontener z typem prostym, nie musimy zakładać czapki czarodzieja i robić magii szablonowej ze SFINAE na czele, by kod się skompilował i działał poprawnie. A co zrobi destruktor typu prostego?
Nic. I całe szczęście! Standard jasno specyfikuje zachowanie w powyższym przypadku: pseudo destruktor.
Kod działa tak, jak ma działać.
Wszyscy wiemy, w jaki sposób wygląda i jak działa instrukcja switch
. W nawiasie podajemy liczbę lub typ wyliczeniowy, w blokach case
możliwe wartości. Ale okazuje się, że zgodnie ze standardem, wewnątrz bloku switch
możemy napisać dowolne wyrażenie, z czego bloki case
, break
i default
mają specjalne znaczenie. Na przykład:
#include <iostream>
int main() {
int n = 3;
int i = 0;
switch (n % 2) {
case 0:
do {
++i;
case 1:
++i;
} while (--n > 0);
}
std::cout << i;
}
Konstrukcja na pierwszy rzut oka bardzo nietypowa, ale rzecz jasna całkowicie poprawna. Móże ona wyglądać znajomo programistom C. Dzięki niej możliwa jest optymalizacja nazywana mechanizmem Duffa.
Po sprawdzeniu warunku, wejdziemy do pętli do...while
, do etykiety case 1
. Tutaj nastąpi pierwsza inkrementacja i
. Potem cała pętla wykona się dwukrotnie, stąd na ekranie pojawi się cyfra 5
. W przypadku, gdybyśmy mieli n=5
na ekranie wypisane zostanie 9 (pierwszy raz i
zostanie zinkrementowane po przejściu do etykiety case 1
, później czterokrotnie wykona się cała pętla).
Trochę bardziej praktycznie
Oprócz osobliwości, są też rzeczy, które mogą nas kopnąć w codziennej pracy z kodem. Spójrzmy na dość prosty przykład, inicjalizowanie stałej referencji w połączeniu z operatorem trójargumentowym:
int main() {
int i = 1;
int const& a = i > 0 ? i : 1;
i = 2;
return a;
}
Na pierwszy rzut oka - warunek jest spełniony: zmienna a
jest więc stałą referencją na i
:
int const& a = i;
Modyfikujemy zmienną, do której mamy referencję, i… coś tu jest nie tak. Program zwraca 1. Godbolt nie może kłamać. Po raz kolejny czytamy standard od deski do deski, no i w końcu znajdujemy odpowiedni paragraf: §7.6.16. Ten punkt standardu dokładnie opisuje operator trójargumentowy. Nasz przypadek nie spełnia żadnego z punktów 2-5 (nie jest to void, nie jest to klasa, itp….). Łapiemy się więc na punkt 6:
Otherwise, the result is a prvalue
Czym jest prvalue
? To to nic innego, jak zmienna tymczasowa. Czyli nie będzie to referencja do zmiennej i
, tylko do zmiennej tymczasowej. Dlaczego? Dlatego, że kompilator bierze pod uwagę obie strony wyrażenia trójargumentowego. Z lewej strony lvalue
, z prawej prvalue
, stąd wydedukowany typ to również prvalue
Podobna pułapka, czeka nas, gdy mamy do czynienia z rzutowaniem:
#include <iostream>
int main() {
int a = '0';
char const &b = a;
std::cout << b;
a++;
std::cout << b;
}
Podobnie jak powyżej, referencja została zainicjalizowana zmienną tymczasową powstałą w wyniku konwersji int
do char
.
UB or not UB?
Na koniec coś zupełnie nieprzydatnego, ale znowu - jasno zdefiniowanego przez standard. Spróbujmy zainicjalizować zmienną, używając samej siebie:
#include <iostream>
int main() {
void *p = &p;
std::cout << bool(p);
}
Czy kod się kompiluje? Tak, standard na to pozwala:
The point of declaration for a name is immediately after its complete declarator and before its initializer (if any)
Czy powyższy kod to undefined behaviour
? Pewnie nie, skoro znajduje się w tym artykule. Mimo, że nie wiemy jaką wartość będzie miało &p
, to wiemy na pewno, że nie będzie ona zerem (nie może mieć wartości NULL
). Na ekranie pojawi się więc 1
.
Po co to wszystko?
Powyższe przykłady pokazują, że standard C++ posiada wiele ciemnych zakamarków. których nie zawsze jesteśmy świadomi. Czy rzeczywiście są one skrajnie niepotrzebne? Wprawdzie nikt nas nie zapyta o nie na rozmowie kwalifikacyjnej. Nie będziemy z nich korzystać regularnie. Może większości z nich nigdy nie zobaczymy. Ale przyjdzie momenet, gdy kompilator rzuci dziwnym błędem, albo co gorsza, dostaniemy błąd prosto od klienta. Wtedy wystarczy jeden rzut oka, jedno niewielkie spojrzenie… W duchu możemy się uśmiechnąć, bo już wiemy:
To rozdział
Lexical convention
, paragraf §5.5.Łatwizna. Potrzymajcie mi kawę.
A wy? Znacie jakieś mało przydatne funkcje, które niepotrzebnie komplikują C++?
Źródła:
cppquiz.org
Standard C++