Jste zde

Zajímavosti z C a C++: Urychlujeme překlad využitím dopředných deklarací

Minule jsme si ukázali, proč nezahrnovat implementaci do hlavičkových souborů. Dnes se podíváme na to, jak urychlit překlad použitím tzv. dopředných deklarací.

Úvod

Pro jednoduchost se zaměřím na C++, ale podobné argumenty platí i pro C.

Dejme tomu, že jsme vytvořili třídu Parser (syntaktický analyzátor), která interně využívá instanci třídy Lexer (lexikální analyzátor), resp. ukazatel na ni. Ta je předaná do konstruktoru. Definice třídy Parser je rozdělena do dvou souborů, Parser.cpp a Parser.h:

// Parser.h
class Parser {
public:
    Parser(Lexer *lexer);
 
    // Veřejné rozhraní třídy Parser.
    // ...
 
private:
    Lexer *lexer;
};
 
// Parser.cpp
#include "Parser.h"
 
Parser::Parser(Lexer *lexer): lexer(lexer) {
    // ...
}
 
// Definice ostatních metod.
// ...

Dále mějme aplikaci, která třídu Parser vytváří a využívá. Pokud bychom kód nechali takto, tak nám překladač při překladu modulu Parser zahlásí chybu, že byl použit neznámý identifikátor Lexer. Většina programátorů to vyřeší tak, že do Parser.h vloží #include Lexer.h:

// Parser.h
#include "Lexer.h"
 
// Zbytek souboru je beze změny.
 
// Parser.cpp
// Beze změny.

Překlad již projde, což je hlavní. Ve zbytku příspěvku bych chtěl ale ukázat alternativu, která vám umožní v mnoha situacích urychlit překlad vašeho projektu bez újmy na udržovatelnosti/čitelnosti.

Dopředné deklarace

V C++ (i C) lze využít tzv. dopředných deklarací (angl. forward declaration). Dopřednou deklarací dáte překladači vědět, že daný identifikátor je určitého typu (tudíž jej bude znát), ale nedáte překladači vědět některé detaily, např. jak je onen objekt definován či jaká je jeho velikost. Pokud chceme napsat dopřednou deklaraci třídy, tak to uděláme takto:

class X;

kde X je název oné třídy. S takto deklarovanou třídu pak můžete dělat následující (převzato odtud):

  • Deklarovat datovou složku třídy jako ukazatel na instanci dané třídy:
    class SomeClass {
        X *pt;
        X &pt;
    };
  • Deklarovat funkce/metody, které mají jako parametr či vrací instanci oné třídy:
    void f1(X);
    X    f2();
  • Definovat funkce/metody, které mají jako parametr či vrací ukazatel na instanci oné třídy:
    void f3(X*, X&) {}
    X&   f4()       {}
    X*   f5()       {}

Co dělat např. nemůžete, tak je použít onu třídu jako bázovou třídu, deklarovat datovou složku třídy onoho typu, definovat funkce, které onen typ používají přímo (tedy nikoliv přes ukazatel) či přistupovat přes ukazatel na dopředně deklarovanou třídu (např. že zavoláte její metodu). Pro příklady mrkněte zde.

Co se týče našeho příkladu z úvodu, tak ona alternativa k #include "Lexer.h" je právě použít dopřednou deklaraci:

// Parser.h
class Lexer;    // <-- dopředná deklarace
 
class Parser {
public:
    Parser(Lexer *lexer);
 
    // Veřejné rozhraní třídy Parser.
    // ...
 
private:
    Lexer *lexer;
};
 
// Parser.cpp
#include "Parser.h"
#include "Lexer.h"   // <-- přesunuto z Parser.h
 
Parser::Parser(Lexer *lexer): lexer(lexer) {
    // ...
}
 
// Definice ostatních metod.
// ...

Samotný #include hlavičkového souboru Lexer.h se nám pak přesune do Parser.cpp, protože v Parser.h jej nebudeme potřebovat.

Moc tomu nerozumím. Jakou to má výhodu?

Dopředné deklarace mají různé výhody (např. při vytváření tříd, kde jednu používá druhou a druhá zase tu první a jsou v různých hlavičkových souborech). Já se zaměřím jen na jednu, která se váže na náš příklad: urychlení překladu. Toto urychlení lze pozorovat ve dvou případech:

  • Při změnách v Lexer.h. Dejme tomu, že dojde ke změně v Lexer.h (kromě změny názvu třídy, samozřejmě). V první variantě se nám musí znovu přeložit Parser.cpp a každý soubor, který (ať už přímo či nepřímo) #includuje Lexer.h a Parser.h. To je proto, že Parser.h #includuje Lexer.h. Je to podobná situace, jako u minulého příspěvku. U druhé varianty, která využívá dopřednou deklaraci, se přeloží pouze Parser.cpp a každý soubor, který #includuje Lexer.h. Všimněte si, že už není nutné znovu překládat každý soubor, který #includuje Parser.h. Proč by uživatelé, kteří využívají Parser měli být nuceni jej znovu překládat, když se změnilo něco, co je využíváno pouze interně? Neměli. Právě proto byste měli využívat dopředné deklarace. Čím větší máte projekt, tím více to oceníte.
  • Lexer.h #includuje mnoho rozsáhlých souborů. Překladač při překladu modulu musí vložit těla všech #includovaných souborů, což znamená jejich načtení z disku (vstupně/výstupní operace jsou pomalé), kontrolu, zda už jej nevložil (s využitím tzv. header guards) a jeho zpracování (lexikální analýza, syntaktická analýza atd.). Čím více takových souborů je, tím je samozřejmě delší doba překladu. Pokud snížíte počet #include, snížíte tím i dobu překladu.

Co doporučuješ?

Předtím, než bezmyšlenkovitě provedete přidání #include "XXX.h" do zdrojáku se vždy zamyslete nad tím, zda by nestačilo použít jen dopřednou deklaraci. Jsou situace, kde by to způsobilo více škody než užitku a ve kterých by to ani nešlo. O těch snad napíši někdy jindy. Ve všech ostatních případech ale doporučuji #include nahradit dopřednou deklarací.

Shrnutí

Abychom si shrnuli tento a minulý příspěvek, pro urychlení překladu doporučuji:

  • implementační detaily vkládat do .cpp souboru, nikoliv do .h souboru,
  • místo #include využít dopředné deklarace (tam, kde to jde a dává to smysl).

Další čtení

Pokud by vás zajímala další diskuse a rady na toto téma, určitě mrkněte do zdrojů uvedených níže.

Přidat komentář