Nagłówki, czyli jak to robić dobrze


2018-08-30, 00:00

Język C++ będąc językiem kompilowanym dostarcza nam mechanizm służący do ukrywania naszych implementacji przed osobami trzecimi - pliki nagłówkowe. Używając ich w odpowiedni sposób, jesteśmy w stanie dostarczyć kod realizujący zadanie, nie odsłaniając całej wiedzy, którą posiadamy. Niestety, pliki nagłówkowe bywają bardzo uciążliwe, jeżeli używa się ich byle jak. Jak zatem radzić sobie z nagłówkami, kiedy te nas nie słuchają?

Kłopotliwe nagłówki

Nagłówki bywają bardzo kłopotliwe. Łatwo o cykliczne zapętlenia, a najdrobniejsza zmiana w najczęściej używanym pliku nagłówkowym może w konsekwencji wywołać re-kompilację całego projektu. Jak to zazwyczaj bywa, na każdy problem przypada conajmniej jedno rozwiązanie. Zanim jednak przejdziemy do analizy problemów związanych z załączaniem plików nagłówkowych, omówmy sobie pokrótce sposób działania tego mechanizmu.

Jak to działa?

Jak wszystkim wiadomo, kompilator kompiluje pliki źródłowe (np. te z rozszerzeniem .cpp) zawsze wtedy, kiedy ich treść przestała być aktualna względem skompilowanego pliku obiektowego. Najczęściej na jeden plik źródłowy przypada jedna jednostka kompilacji (plik obiektowy), ale pamiętajmy, że to zachowanie można z łatwością zmienić.

Pliki źródłowe mogą zawierać dyrektywy preprocesora, w tym również dyrektywę #include. Działanie tej instrukcji jest bardzo proste - w jej miejsce zostaje przeklejona zawartość pliku, do którego ona kieruje. Stąd, jeżeli zmieni się zawartość pliku nagłówkowego załączonego w pliku źródłowym, to plik źródłowy będzie wymagał ponownej kompilacji. Jeżeli plik nagłówkowy zawiera dyrektywy #include, to one również zostają rekursywnie zastąpione zawartościami plików docelowych. W efekcie, do pliku źródłowego w miejsce wystąpienia instrukcji #include zostaje przeklejony jeden plik nagłówkowy, będący sumą wszystkich plików nagłówkowych napotkanych po drodze.

Należy również przypomnieć, że istnieje dyrektywa #pragma once¹, która jest alternatywą dla strażnika załączania. Zapobiega ona wielokrotnemu wykorzystaniu jednego pliku nagłówkowego w obrębie jednostki kompilacji.

Klasyczny przykład załączania cyklicznego

Przeanalizujmy taki oto przykład:

// A.hpp
#pragma once

#include "B.hpp"

class A {

public:
    void doSomething(B);
};

// A.cpp
#include "A.hpp"

void A::doSomething(B) {}

// B.hpp
#pragma once

#include "A.hpp"

class B {
    void doSomethingElse(A);
};

// B.cpp
#include "B.hpp"

void B::doSomethingElse(A) {}

Mamy tutaj klasyczny przykład cyklicznej zależności w nagłówkach. Preprocesor do nagłówka A.hpp musi załączyć zawartość pliku B.hpp, a do nagłówka B.hpp musi wkleić zawartość pliku A.hpp. Gdyby nie instrukcja #pragma once, dostalibyśmy komunikat o błędzie:

error: #include nested too deeply

Jednakże, dzięki wykorzystaniu tej instrukcji mamy komunikat, który mówi nam coś więcej. Po pełnej treści tego komunikatu jesteśmy w stanie wywnioskować miejsce wystąpienia problemu:

error: unknown type name 'A'

No dobra, ale… dlaczego dostaliśmy komunikat o błędzie? Skupmy się na treści pliku A.cpp:

  1. Na samym wstępie załączamy nagłówek #include "A.hpp", zatem przeklejamy jego zawartość do pliku źródłowego.
  2. Przechodzimy do pliku A.hpp. Wewnątrz widzimy, że ten pierwsze co robi, to załącza plik nagłówkowy B.hpp. Wklejamy w takim razie jego zawartość w miejsce wystąpienia odpowiadającej mu instrukcji #include w pliku źródłowym.
  3. Przechodzimy do pliku B.hpp. Instrukcja #include "A.hpp" zostaje usunięta, ponieważ plik A.hpp został już załączony do tej jednostki kompilacji (gwarantuje to instrukcja #pragma once). Dalej w pliku B.hpp mamy deklarację klasy, a w niej deklarujemy metodę z parametrem typu A. Niestety, w tym miejscu kompilator zgłosi błąd, ponieważ nie wie czym jest typ A. Deklaracja klasy A znajduje się niżej, za miejscem pierwszego jej użycia.

Dokładnie ten sam scenariusz zachodzi w jednostce kompilacyjnej B.cpp. Na szczęście język C++ jest wyposażony w narzędzie pozwalające poprawić ten błąd.

Forward declaration

W zasadzie cały algorytm ładowania plików nagłówkowych, który opisałem powyżej zachował się poprawnie. Tym, co należy uczynić jest poinformowanie kompilatora, że “typ A gdzieś istnieje (obiecuję!), niech później linker to sprawdzi”. Oczywiście, taka obietnica nosi za sobą konsekwencje: kompilator wie, że typ A gdzieś istnieje, ale nic więcej o nim nie wie. Takie sformułowanie nosi nazwę deklaracji naprzód (forward declaration). Pro tip: deklarować istnienie klasy możemy wielokrotnie w obrębie jednej jednostki kompilacji. Co należy w takim razie zrobić, aby pozbyć się błędu? Spójrzmy poniżej:

// B.hpp
#pragma once

class A;               // <---- Tutaj obiecujemy, że klasa A gdzieś istnieje

class B {
    void doSomethingElse(A);
};

Dodatkowo wspomnę, że deklarację naprzód możemy stosować zarówno w pliku nagłówkowym, jak i w pliku źródłowym - zaleca się, aby stosować ją w miejscu, w którym istnieje potrzeba użycia.

Deklaracja w pliku źródłowym

Powyżej przedstawiłem deklarację w pliku nagłówkowym, w którym to zazwyczaj wystarczy zadeklarować istnienie samej klasy. Nieco inna sytuacja może wyniknąć w pliku źródłowym, który używa klasy wraz z jej polami i/lub metodami. Pamiętajmy, że w takiej sytuacji należy zadeklarować również istnienie wykorzystywanych pól/metod.

Kiedy forward declaration zawodzi…

Niestety, nie każdy problem związany z cyklicznością nagłówków możemy rozwiązać poprzez zastosowanie forward declaration. Przeanalizujmy poniższy listing:

// A.hpp
#pragma once

#include "B.hpp"

class B;

class A {
    B b;
};

// B.hpp
#pragma once

#include "A.hpp"

class A;

class B {
    A a;
};

Podczas komplilacji powyższego kodu otrzymamy komunikat o błędzie:

error: field has incomplete type 'B'

Oh… wait! Przecież wszystko mamy jak trzeba! Forward declaration i w ogóle… Ale czy aby na pewno wszystko jest OK? Znowu, prześledźmy nasz kod literka po literce.

Wyobraźmy sobie, że gdzieś w kodzie źródłowym chcemy utworzyć instancję klasy A. Kompilator już na wstępie musi wiedzieć, ile pamięci musi zarezerwować dla tego obiektu. Ponieważ zastosowaliśmy forward declaration, kompilator nie jest w stanie znaleźć informacji o wielkości tego typu. On wie tylko tyle, że ten typ istnieje.

Na ratunek przychodzą wskaźniki

Rozwiązaniem naszego problemu będzie… utworzenie wskaźników² lub referencji w tych miejscach, w których on występuje. Ale… dlaczego? Aby odpowiedzieć sobie na to pytanie, należy zastanowić się, co tak na prawdę zostanie wliczone do wielkości obiektu. Mamy wskaźnik, zatem wewnątrz klasy potrzeba zarezerwować jedynie pamięć na jego adres. Niepotrzebna kompilatorowi w tym momencie wiedza, z czego składa się typ, bo wie, że i tak potrzebuje tylko znać adres obiektu znajdującego się w pamięci. W sumie, to nie ma co w tej chwili nawet mówić o obiekcie, ponieważ obiekt w pamięci jeszcze nie został utworzony.

Zatem, aby ponownie pozbyć się komunikatu o błędzie, należy zrobić np. tak:

// A.hpp
#pragma once

#include <memory>
#include "B.hpp"

class B;

class A {
    std::shared_ptr<B> b;
};

// B.hpp
#pragma once

#include <memory>
#include "A.hpp"

class A;

class B {
    std::weak_ptr<A> a;
};

Gdzie forward declaration nie pomoże?

Forward declaration działa wszędzie tam, gdzie nie potrzebujemy wiedzy o rozmiarze konkretnego typu. Zatem wszędzie tam, gdzie używamy wskaźników i referencji. Dodatkowo, deklaracja naprzód zadziała również dla typów zwracanych przez metody oraz ich parametrów, niezależnie od tego, czy są wskaźnikami, czy nie.

Jedna zmiana vs re-kompilacja całego projektu

Wyobraźmy sobie scenariusz: w projekcie mamy trzy klasy: A, B, C. Nagłówek klasy A jest załączana do nagłówka klasy B, a nagłówek klasy B załączany jest do nagłówka klasy C. Wygląda to mniej więcej tak:

// A.hpp
#pragma once

class A {
};

// B.hpp
#pragma once

#include "A.hpp"

class B {
public:
    B();
};

// B.cpp
#include "B.hpp"

B::B() {
    A a;
}

// C.hpp
#pragma once

#include "B.hpp"
class C {
};

Zakładamy, że skoro klasa C nie załącza nagłówka klasy A, to oznacza, że nie potrzebuje ona klasy A do działania. Teraz, zmodyfikujmy klasę A, dodając do niej konstruktor i przekompulujmy kod. Jakie wyjście otrzymałem?

#!/bin/bash
Scanning dependencies of target headers
[ 60%] Building CXX object CMakeFiles/headers.dir/classes/B.cpp.o
[ 60%] Building CXX object CMakeFiles/headers.dir/classes/A.cpp.o
[ 60%] Building CXX object CMakeFiles/headers.dir/classes/C.cpp.o
[ 80%] Linking CXX executable headers
[100%] Built target headers

Hola, hola! Klasa C również została skompilowana! Narzekać można na kompilator, ale ten zrobił to, co należy: zauważył, że zależności klasy C się zmieniły (tak, nagłówek załączany do innego nagłówka staje się jego zależnością) zatem przekompilował klasę C, aby jej plik obiektowy był aktualny. Nie ważne, że klasa C nie używa klasy A nigdzie u siebie.

Miejsce załączania pliku nagłówkowego jest ważne

Zróbmy drobny eksperyment. Skoro klasa B nie wykorzystuje nigdzie w swoim nagłówku klasy A (robi to w pliku źródłowym), to przenieśmy załączanie klasy A do pliku źródłowego. Kod zmieni się wtedy następująco:

// A.hpp
#pragma once

class A {
};

// B.hpp
#pragma once

class B {
public:
    B();
};

// B.cpp
#include "B.hpp"

#include "A.hpp"

B::B() {
    A a;
}

// C.hpp
#pragma once

#include "B.hpp"
class C {
};

Przekompilujmy całość, aby projekt się zsynchronizował. Kiedy już zsynchronizowaliśmy projekt do naszych zmian, zmodyfikujmy ponownie klasę A i skompilujmy projekt. Mi pojawiło się na wyjściu:

#!/bin/bash
Scanning dependencies of target headers
[ 40%] Building CXX object CMakeFiles/headers.dir/classes/A.cpp.o
[ 40%] Building CXX object CMakeFiles/headers.dir/classes/B.cpp.o
[ 60%] Linking CXX executable headers
[100%] Built target headers

Voila! Klasa C nie skompilowała się. Dlaczego? Stało się tak, ponieważ klasa A nie znajduje się w jednostce kompilacyjnej klasy C. Używając domyślnych ustawień kompilacji, każdy plik .cpp to osobna jednostka kompilacji, która nie wie nic o drugiej (dopóki jej tego nie wskażemy). Tak więc zapamiętajmy: to, gdzie załączamy plik nagłówkowy, ma znaczenie. Zatem, aby zminimalizować ilości plików re-kompilowanych przy zmianie kodu, załączajmy pliki nagłówkowe w tych miejscach, w których faktycznie ich potrzebujemy.

Zasady pracy z plikami nagłówkowymi

Dzisiejszy wpis można zawrzeć w kilku zdaniach:

  1. Pliki nagłówkowe załączamy tam, gdzie faktycznie są potrzebne.
  2. Stosujemy strażników w nagłówkach. Tam gdzie pojawia się problem z cyklicznym ładowaniem nagłówków, stosujemy deklarację naprzód.
  3. W sytuacjach, kiedy deklaracja naprzód zawiedzie, używajmy wskaźników lub referencji.

Te problemy istnieją na prawdę

Oczywiście, problemy o którysz piszę nie są wyimaginowane. Języki wysokiego poziomu często przyzwyczajają nas do masy wygód, kosztem wydajności. Przesiadając się z powrotem na C++ łatwo wpaść w pułapki, których nigdzie indziej nie ma.

Poniżej true story.

True story

Niech pierwszy rzuci kamieniem, kto nie miał podobnych problemów :)

Podsumowanie

Nauczyliśmy się dzisiaj, jak radzić sobie w sytuacji, kiedy dwa zasoby polegają na sobie wzajemnie. Rozwiązaliśmy nasze problemy, stosując deklarację naprzód oraz korzystając ze wskaźników tam, gdzie nie można tego zrobić inaczej. Dodatkowo poznaliżmy zasady pracy z nagłówkami tak, aby kompilacja projektu nie trwała wieki.


¹ W standardzie C++ istnieje inna wersja strażnika: oparta o dyrektywy #ifndef, #define, #endif. Jednakże dyrektywa #pragma once mimo, że nie jest częścią standardu, jest zaimplementowana w większości popularnych kompilatorów

² Preferujemy używanie smart pointerów, zamiast operatora new ;)



Marcin Kukliński

Zawodowo backend developer, hobbystycznie pasjonat języka C++. Po godzinach poszerza swoją wiedzę na takie tematy jak teorii kompilacji oraz budowa formatów plików. Jego marzeniem jest stworzyć swój własny język programowania.

Pssst! Używamy Cookies. Poprzez używanie naszego serwisu zgadzasz się na odczytywanie i zapisywanie Cookies w swojej przeglądarce.