C++ Core Guidelines: Filozofia.
Zaczniemy od naprawdę ogólnych zasad. Źródłem większości przykładów jest oryginalny dokument C++ Core Guidelines.
P1. Wyrażaj swoje pomysły bezpośrednio w kodzie.
Kompilatory, podobnie jak większość programistów, nie czytają komentarzy. Sam kod musi być w takiej sytuacji maksymalnie konkretny, co pozwoli na jego łatwy odbiór przez człowieka i zapewni bardzo wysoki poziom odporności na błędy, które może łatwo wyłapać kompilator.
Przykład 1:
class Date
{
// ...
public:
Month month() const; // tego chcemy :)
int month(); // tego unikamy !
// ...
};
Definiujesz klasę Date i tworzysz metodę, która zwraca miesiąc ? Niech właśnie to obrazuje twój kod. Niech metoda będzie zadeklarowana jako stała. Month może być w tym przykładzie np. silnym typem wyliczeniowym, zawierającym zdefiniowane miesiące.
Co osiągnęliśmy ? Metoda bardzo dokładnie opisuje swoje działanie, a kompilator eliminuje cały zestaw potencjalnych błędów, związanych ze zwracanymi wartościami albo nadmiarowym kodem, który chciałby zmienić stan obiektu.
Przykład 2:
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
int index = -1;
for (int i = 0; i < v.size(); ++i)
{
if (v[i] == val)
{
index = i;
break;
}
}
// ...
}
Załóżmy, że istnieje funkcja f, która wygląda jak powyżej. Jedną z jej głównych części jest kod, który sprawdza, pod jakim indeksem w wektorze znajduje się wartość wprowadzona przez użytkownika. Oczywiście na pierwszy rzut oka nie można stwierdzić, co to za instrukcje. Trzeba poświęcić czas i uwagę…
Po analizie okazuje się, że kod jest bezsensownym powieleniem czynności, która jest tak powszechna, że już dawno zasłużyła na swoje miejsce w bibliotece standardowej języka. Nagłowek <algorithm> zawiera definicję funkcji std::find, która robi dokładnie to, co powyższe kilka linijek (zapewne robi to też lepiej).
Finalnie mamy:
void f(vector<string>& v)
{
string val;
cin >> val;
// ...
auto p = find(begin(v), end(v), val);
// ...
}
Kod jest krótszy, odporny na błędy i niesamowicie jasny. Użyliśmy tutaj implementacji podstawowego algorytmu z biblioteki standardowej oraz słowa kluczowego auto.
Konkluzja: Programista powinien znać bibliotekę standardową, przynajmniej na podstawowym poziomie. Powinien wiedzieć, kiedy użycie gotowych elementów ma sens i to wykorzystywać.
P.2 Pisz kod zgodny ze standardem ISO C++.
Język programowania C++ jest technologią ustandaryzowaną. Oznacza to, że istnieje oficjalny zestaw wytycznych, które powinny być brane pod uwagę podczas tworzenia oprogramowania w tym języku. Te same zasady są podstawą, na której bududowane są kompilatory na różne platformy.
Standard ISO C++ definiuje semantykę języka oraz reguły jego działania w określonych sytuacjach. Wszystko po to, aby zapewnić deklarowaną uniwersalność i przenośność kodu pomiędzy różnymi platformami. Dlatego zaleca się korzystane z kompilatorów, które oficjalnie wspierają i weryfikują kod źródłowy pod względem poprawności i zgodności z ISO C++. Powinno się też unikać sytuacji w kodzie źródłowym, które są określone jako Undefined Behaviour (UB) – standard nie definiuje, jakiego wyniku powinniśmy w takiej sytuacji oczekiwać.
Istnieją oczywiście sytuacje, w których programiści poruszają się poza obszarami zdefiniowanymi przez standard albo muszą zdefiniowane zasady ograniczyć (np. zrezygnować z dynamicznej alokacji pamięci). Często sami twórcy kompilatorów wbudowują w nie rozszerzenia, które nie są częścią oficjalnych wytycznych i wspierają np. jedynie konkretny system operacyjny. Trzeba w takich sytuacjach pamiętać, że niestandardowy kod najlepiej odseparować i utrzymywać niejako osobno od jego pozostałej części. Pozwoli to na uniknięcie problemów i ograniczenie kosztów utrzymania oraz ułatwi ewentualną migrację do implementacji zgodnej z ISO C++.
P.3 Staraj się pisać kod, który jasno wyraża swoje intencje.
Kod źródłowy powinien być oczywisty. Jeśli potrafimy od razu stwierdzić, że kod realizuje to, co powinien – jest bardzo dobrze. Brzmi to może nieco dziwnie i podobnie do punktu P.1, ale spójrzmy na przykład.
Przykład:
int i = 0;
while (i < v.size())
{
// ... operacje na v[i]
}
Zasadniczo poprawny składniowo kod w C++. Ale…
Intencją jest tak naprawdę operowanie na kolejnych elementach – nic więcej. Powyższy kod tego jednak jasno nie pokazuje.
Zamiast tego mamy jawnie zdefiniowany indeks przed pętlą, co umożliwia bezsensowną zmianę tej wartości, a także niepotrzebnie wydłuża czas życia zmiennej (będzie dostępna również w zakresie za pętlą). Takie szczegóły implementacyjne, związane z prostą iteracją nie są nam do niczego potrzebne – zaciemniają tylko obraz i utrudniają zrozumienie, co tak zaprawdę realizuje kod źródłowy.
Porównajmy to z linijkami poniżej:
for (const auto& x : v) { /* operacje związane z x */ }
for (auto& x : v) { /* operacje związane z x */ }
for_each(v, [](int x) { /* operacje związane z x */ });
for_each(par, v, [](int x) { /* operacje związane z x */ });
Korzystając z zakresowej pętli for, ukrywamy kod związany z iteracją po elementach. Pętla operuje na referencji do, stałych lub nie, elementów. W tym momencie kod jest jasny, a dostęp do elementów jawnie, bądź nie, zabezpieczony.
Dużo lepszym rozwiązaniem jest też użycie gotowych algorytmów w połączeniu z wyrażeniami lambda, co pokazują kolejne dwie linie. Wnioski są podobne.
Oczywiście niektóre konstrukcje języka są bardziej opisowe i zrozumiałe, a inne mniej. Powinniśmy jednak cały czas poznawać nowe mechanizmy i bibliotekę standardową, żeby móc stale upraszczać i rozwijać nasz kod – to jest właśnie filozofia nowoczesnego języka C++.
P.4 W idealnej sytuacji, program powinien zapewniać statyczne bezpieczeństwo typów.
Jest to oczywiście teoretyczna sytuacja idealna – już w czasie kompilacji wszystkie typy są znane i niezmienne, a błędy związane z typowaniem nie istnieją.
W realnym świecie mamy jednak co najmniej kilka obszarów, które generują problemy, m. in. unie, rzutowania, błędy związane z zakresami, „gubienie” typów i rozmiarów tablic oraz tzw. konwersje zawężające.
Nie są to, jak widać, błahostki. Obszary te generują naprawdę poważne i paskudne błędy, w tym naruszenia bezpieczeństwa i częste awarie aplikacji.
Jak pozbyć się problemów ?
Sposobów jest kilka, ale najlepszym jest ograniczenie lub kompletna rezygnacja z używania problematycznych konstrukcji. Możemy je zastąpić w następujący sposób:
- unie – użyj szablonu std::variant z C++17
- rzutowania – ogranicz ich użycie do niezbędnego minimum (mogą w tym pomóc szablony)
- błędy związane z zakresami – użyj szablonu gsl::span z GSL (Guidelines Support Library)
- gubienie typów i rozmiarów tablic – użyj szablonu gsl::span z GSL (Guidelines Support Library)
- konwersje zawężające – ogranicz ich użycie do minimum, korzystaj z operatora rzutowania narrow_cast (GSL)
P.5 Preferuj kontrolę na etapie kompilacji, zamiast kontroli na etapie wykonania.
Dlaczego ? Z powodu większej czytelności i większej wydajności kodu. Jeśli błąd jest wyłapany na etapie kompilacji, nie trzeba dodawać kodu obsługującego błąd w trakcie wykonania.
Przykład 1.
// Int jest aliasem liczby całkowitej
int bits = 0;
for (Int i = 1; i; i <<= 1)
{
++bits;
}
if (bits < 32)
{
cerr << "Int too small\n"
}L
Kolejny przykład kodu, który teoretycznie jest poprawny, jednak można go drastycznie uprościć niewielkim wysiłkiem. Może wyglądać tak:
// Int jest aliasem liczby całkowitej
static_assert(sizeof(Int) >= 4);
Kod jest krótki i nadal doskonale zrozumiały. Statyczna asercja zapewnia sprawdzenie rozmiaru liczby całkowitej w czasie kompilacji, przez co cały kod obsługujący niepoprawną sytuację w czasie wykonania stał się zbędny.
Przykład 2.
void read(int* p, int n); // wczytaj maksymalnie n liczb całkowitych do *p
int a[100];
read(a, 1000); // źle
lepiej zrobić to tak:
void read(span<int> r); // wczytaj dane do zakresu liczb całkowitych r
int a[100];
read(a); // kompilator wyliczy dokładną liczbę elementów
Wykorzystując szablon zakresu gsl::span, pozbywamy się ewentualnych błedów, związanych z zakresami.
Konkluzja: Jeśli można coś sprawdzić już na etapie kompilacji – zrób to. Unikniesz wielu problemów i uprościsz swój kod.
P.6 Co nie może być sprawdzone podczas kompilacji, powinno być możliwe do wykrycia podczas wykonania.
W idealnej sytuacji wyłapujemy wszystkie błędy na etapie kompilacji lub wykonania. Niemożliwe jest wyłapanie wszystkich błędów podczas kompilacji. Często nie ma też sensu wyłapywanie absolutnie wszystkich błędów na etapie wykonania – nie jest to ani szybkie, ani tanie.
Powinniśmy jednak pisać programy, które generalnie da się sprawdzić na obecność błędów, pod warunkiem użycia odpowiednich zasobów.
Kilka ciekawych przykładów.
// kompilowane osobno, być może dynamicznie ładowane
extern void f(int* p);
void g(int n)
{
// źle: liczba elementów nie jest przekazywana do funkcji f
f(new int[n]);
}
Powyższy kod bardzo skutecznie eliminuje kluczową informację o liczbie elementów, co krytycznie wpływa na możliwość statycznej analizy tego kodu. Dynamiczna kontrola może być bardzo trudna w sytuacji, kiedy funkcja f jest częścią ABI (Application Binary Interface). W taki sposób można doprowadzić do sytuacji, w której detekcja błędów jest bardzo trudna z powodu decyzji projektowych.
Oczywiście można dodać informację o liczbie elementów:
// kompilowane osobno, być może dynamicznie ładowane
extern void f2(int* p, int n);
void g2(int n)
{
f2(new int[n], m); // źle: możliwe podanie błędnej liczby elementów
}
Generalnie jawne przekazywanie liczby elementów do funkcji jest dużo lepszym i częściej spotykanym rozwiązaniem, niż poleganie na domysłach bądź dziwnych sposobach liczenia liczby elementów.
Napotykamy jednak kolejne problemy. Prosta pomyłka podczas pisania może wprowadzić kolejny uciążliwy błąd. Na dodatek związek pomiędzy oboma argumentami jest bardziej domyślny, niż jawnie wskazany. Niejasne jest też to, czy funkcja f powinna po wszystkim zwolnić pamięć, na którą wskazuje p.
Możemy też zmienić nasz kod w taki sposób:
// kompilowane osobno, być może dynamicznie ładowane
// zakładamy, że kod wywołujący jest kompatybilny na poziomie ABI,
// używa kompatybilnego kompilatora i tej samej implementacji stdlib
extern void f3(unique_ptr<int[]>, int n);
void g3(int n)
{
f3(make_unique<int[]>(n), m); // źle: przekazanie własności i rozmiaru osobno
}
Komentarz mówi wszystko – sytuacja nadal nas nie zadowala. Musimy zatem przekazać wskaźnik i rozmiar razem, zintegrowane w jednym obiekcie:
extern void f4(vector<int>&); // kompilowane osobno, być może dynamicznie ładowane
extern void f4(span<int>); // kompilowane osobno, być może dynamicznie ładowane
// zakładamy, że kod wywołujący jest kompatybilny na poziomie ABI,
// używa kompatybilnego kompilatora i tej samej implementacji stdlib
void g3(int n)
{
vector<int> v(n);
f4(v); // przekaż referencję, zachowaj własność
f4(span<int>{v}); // przekaż widok, zachowaj własność
}
Takie rozwiązanie przenosi liczbę elementów jako integralną, wewnętrzną część obiektu. Dzięki temu błędy są bardzo mało prawdopodobne, a kontrola w czasie wykonania zawsze jest możliwa do przeprowadzenia.