Start Kontakt

C++20 - moduły

W tym artykule chciałbym dokładniej zaprezentować moduły. Najpierw na przykładach pokażę, czym one są i jak można ich używać, a następnie w podsumowaniu omówię ich zalety w porównaniu z dotychczas używanymi plikami nagłówkowymi.

Przykładowe kody prezentowane w tym artykule można pobrać z Google Drive.

Moduły są mechanizmem służącym do organizowania i porządkowania kodu, co ma się przyczyniać do jego lepszej czytelności. Podobnie jak pliki nagłówkowe, również moduły są komponentami, które mają zawierać informacje dotyczące określonych funkcjonalności. Ważne przy okazji jest to, że takie komponenty powinny być w miarę możliwości "czarnymi skrzynkami", czyli ich użytkownik nie może być zmuszany do tego, aby dokładnie wiedzieć, jak działają.

Moduły eksportują pewne jednostki, takie jak funkcje czy zmienne, a programista jest niejako ich konsumentem i je importuje.

Definicja prostego modułu

Zdefiniujmy więc pierwszy, prosty moduł. Załóżmy, że tworzymy przygodową grę komputerową i musimy w jakiś sposób reprezentować gracza. Odpowiednie definicje umieścimy więc w module.

Samo utworzenie pustego modułu w środowisku Visual Studio jest łatwe. Prawym klawiszem myszy klikamy nazwę projektu, a następnie z menu wybieramy opcję Add/Module. Pojawia się okno dialogowe, za pomocą którego można podać nazwę modułu. W przypadku Visual Studio rozszerzeniem modułów jest .ixx. Inne kompilatory mogą używać odmiennych rozszerzeń.

W module umieśćmy następujący kod:

export module player;

import <string>;

export
{
    enum class Race {HUMAN, HOBBIT, ELF, ORK };
    enum class Profession { WARRIOR, TRADER, PEASANT, WIZARD };

    const std::string getPlayerName(void);
    void setPlayerName(const std::string name);

    const Race getPlayerRace(void);
    void setPlayerRace(const Race race);

    const Profession getPlayerProfession(void);
    void setPlayerProfession(const Profession prof);
}

std::string playerName;
Race playerRace;
Profession playerProfession;

const std::string getPlayerName(void)
{
    return playerName;
}

void setPlayerName(const std::string name)
{
    playerName = name;
}

const Race getPlayerRace(void)
{
    return playerRace;
}

void setPlayerRace(const Race race)
{
    playerRace = race;
}

const Profession getPlayerProfession(void)
{
    return playerProfession;
}

void setPlayerProfession(const Profession prof)
{
    playerProfession = prof;
}

Na początku modułu musi się pojawić nazwa modułu poprzedzona słowami module oraz export, aby można było wyeksportować określone elementy. Co ciekawe, nazwa ta nie musi odpowiadać nazwie pliku. Moglibyśmy nazwać moduł player, a plik player_module.ixx i wszystko działałoby poprawnie.

Elementy, które mają być dostępne z zewnątrz, umieszczamy w tzw. bloku eksportu. Jest to po prostu szereg deklaracji umieszczonych w nawiasach klamrowych, przed którymi występuje słowo export. Kompilator wie, że te składniki muszą być dostępne dla programisty używającego modułu. Jest to więc interfejs modułu. Alternatywnie można byłoby uzyskać taki sam rezultat umieszczając przed każdą deklaracją słowo export, ale w przypadku większej liczby elementów prościej jest użyć bloku eksportu.

Po interfejsie pojawia się obszar implementacji, w którym znajdują się zarówno funkcje, których deklaracje eksportujemy, jak również te elementy, które nie powinny być widoczne z zewnątrz. W tym przypadku są to deklaracje zmiennych, np. playerName czy playerRace. Próba odwołania się do takiej zmiennej zakończy się błędem kompilatora.

Przetestujmy nasz moduł. Oto odpowiednia funkcja main:

int main()
{
    setPlayerName("Arnold");
    std::cout << getPlayerName() << std::endl;
}

Zgodnie z oczekiwaniami, uzyskaliśmy następujący wynik:

Arnold

A teraz sprawdźmy to, o czym pisałem przed chwilą - spróbujmy uzyskać dostęp do zmiennej playerName:

int main()
{
    setPlayerName("Arnold");
    std::cout << getPlayerName() << std::endl;
    std::cout << playerName << std::endl;
}

Co prawda IntelliSense nie podświetliło na czerwono zmiennej playerName (błąd!), ale przynajmniej kompilator był bardziej inteligentny i wyrzucił komunikat Error C2065 'playerName': undeclared identifier.

Oprócz tego pojawił się jednak dziwny błąd There are too many errors for the IntelliSense engine to function properly, some of which may not be visible in the editor, co oznacza, że rzeczywiście istnieje jakiś problem z IntelliSense.

Jeśli przenieślibyśmy deklarację zmiennej do bloku eksportu, kompilacja oczywiście zakończyłaby się sukcesem.

Podział modułu na plik interfejsu i implementacji

Nasz moduł jest poprawny, ale w rzeczywistości modułów nie tworzy się w taki sposób. Deklaracje umieszcza się w pliku interfejsu, a implementację w innym pliku (zwykłym .cpp). Wykonajmy w takim razie działanie wydzielenia fragmentu odpowiedzialnego za implementację i umieśćmy go gdzie indziej. Oto nowo utworzony plik implementacji Player.cpp:

module player;

std::string playerName;
Race playerRace;
Profession playerProfession;

const std::string getPlayerName(void)
{
    return playerName;
}

void setPlayerName(const std::string name)
{
    playerName = name;
}

const Race getPlayerRace(void)
{
    return playerRace;
}

void setPlayerRace(const Race race)
{
    playerRace = race;
}

const Profession getPlayerProfession(void)
{
    return playerProfession;
}

void setPlayerProfession(const Profession prof)
{
    playerProfession = prof;
}

A to plik interfejsu Player.ixx:

export module player;
import <string>;

export
{
    enum class Race {HUMAN, HOBBIT, ELF, ORK };
    enum class Profession { WARRIOR, TRADER, PEASANT, WIZARD };

    const std::string getPlayerName(void);
    void setPlayerName(const std::string name);

    const Race getPlayerRace(void);
    void setPlayerRace(const Race race);

    const Profession getPlayerProfession(void);
    void setPlayerProfession(const Profession prof);
}

Na początku pliku implementacji umieściliśmy jedynie wiersz module player; informujący, że jest to moduł o określonej nazwie. Nie musieliśmy dołączać żadnej informacji o pliku interfejsu, kompilator sam go odnalazł. Pamiętajmy, że wszelkie importy muszą być zawsze umieszczane po deklaracji modułu.

Co ciekawe, nie dołączyliśmy wiersza importującego moduł string. Po prostu kompilator odnalazł ten moduł w pliku interfejsu Player.ixx.

Partycje modułów

Nasz moduł interfejsu jest prosty, ale co zrobić w przypadku, gdyby był skomplikowany? Można go podzielić na tak zwane partycje, które są bardziej specjalizowane i obejmują fragmenty funkcjonalności pierwotnego modułu. W naszym przypadku moglibyśmy wydzielić fragment zawierający deklaracje typów wyliczeniowych i umieścić go w oddzielnym pliku PlayerEnums.ixx:

export module player:enums;

export
{
    enum class Race { HUMAN, HOBBIT, ELF, ORK };
    enum class Profession { WARRIOR, TRADER, PEASANT, WIZARD };
}

Na samym początku pliku eksportujemy interfejs, ale uwaga - określamy przy tym partycję. Jej nazwa enums pojawia się po znaku dwukropka umieszczonym za nazwą głównego modułu interfejsu player.

A oto zmodyfikowany plik Player.ixx:

export module player;
import :enums;

import <string>;

export
{
    const std::string getPlayerName(void);
    void setPlayerName(const std::string name);

    const Race getPlayerRace(void);
    void setPlayerRace(const Race race);

    const Profession getPlayerProfession(void);
    void setPlayerProfession(const Profession prof);
}

Aby główny moduł interfejsu player mógł skorzystać z partycji enums, należy ją w nim zaimportować. To właśnie robimy w wierszu import :enums;. Zauważmy, że nie podajemy nazwy głównego modułu player, lecz jedynie dwukropek i nazwę partycji. Podanie nazwy głównego modułu (czyli import player:enums;) spowoduje powstanie błędu kompilacji.

W niektórych kompilatorach należy dodatkowo przed słowem import umieścić słowo export, czyli export import :enums;. Powoduje to udostępnienie tego, co zostało zaimportowane z partycji. Okazuje się jednak, że w Visual Studio słowo export nie jest wymagane - program działa poprawnie bez jawnego wymuszenia eksportu elementów z partycji:

int main()
{
    setPlayerRace(Race::ELF);
    std::cout << static_cast<int>(getPlayerRace()) << std::endl;
}

Podobno w C++20 można również dzielić pliki implementacji na partycje. Niestety, nie udało mi się tego przetestować z sukcesem - postępowałem zgodnie z zaleceniami, a wciąż uzyskiwałem błąd module partition 'enums' for module unit 'player' was not found. Podejrzewam, że ta funkcjonalność nie została jeszcze poprawnie wdrożona w Visual Studio. Jeśli któryś z Czytelników tego artykułu odniósł sukces podczas testów z partycjami plików implementacji, bardzo proszę o kontakt.

Moduł globalny

Ze względów na kompatybilność ze starszym kodem, wprowadzono pojęcie tzw. modułu globalnego. Po prostu to wszystko, co powstało przed C++20, jest zawarte w module globalnym. Moduł globalny deklaruje się formalnie słowem module; bez podawania żadnej nazwy. Taka deklaracja przydaje się na przykład wtedy, gdy do modułu potrzebujemy dołączyć jakieś standardowe nagłówki, a nie inne moduły. Dołączanie takich nagłówków należy wykonać w obszarze modułu globalnego.

W obszarze modułu globalnego nie może się znaleźć nic innego oprócz dyrektyw preprocesora takich jak #include i innych.

module;

#include "my_old_header.h"
    
module myNewModule;
    
    ...
    ...

Moduły klas

Do tej pory tworzyliśmy moduły ze zwykłymi funkcjami i deklaracjami zmiennych. A co z klasami? Okazuje się, że moduły klas różnią się trochę od modułów funkcji. Pokażmy to na przykładzie. Załóżmy, że do gry musimy utworzyć klasę odpowiadającą za przechowywanie informacji o państwach. Oto dwa pliki modułów - interfejsu Country.ixx i implementacji Country.cpp:

export module Country;

import <string>;

export class Country
{
public:

    void setName(const std::string n);
    const std::string getName(void) const;

private:
    std::string name;
};
module Country;

void Country::setName(const std::string n)
{
    name = n;
}

const std::string Country::getName(void) const
{
    return name;
}

Plik interfejsu Country.ixx zawiera standardowy wiersz z eksportem modułu Country. Poniżej niego znajduje się definicja klasy Country poprzedzona instrukcją export.

Podobnie, w pliku implementacji Country.cpp widzimy na początku instrukcję definiującą moduł Country.

Różnica w porównaniu z modułami ze zwykłymi funkcjami polega na tym, że nazwy modułu, klasy oraz obu plików muszą być takie same. Jeśli na przykład zmienilibyśmy nazwę klasy na Countries, pojawiłby się błąd kompilacji. Nie pomogłaby nawet zmiana nazwy modułu również na Countries, ponieważ nazwy plików pozostałyby takie, jak poprzednio (czyli Country.*). Nie wiem, czy jest to "feature", czy też błąd implementacji w Visual Studio, bo nie testowałem tego na innym kompilatorze.

Moduły a pliki nagłówkowe

Każdy z programistów C++ dobrze wie, jakie problemy mogą czasami sprawić pliki nagłówkowe - trzeba stosować specjalne rozwiązania, aby nie dołączać ich wielokrotnie (tzw. include guard), używać deklaracji wyprzedzających itp. Poza tym każda modyfikacja nagłówka wiąże się często z przekompilowaniem całego, czasem bardzo złożonego kodu.

Ponieważ moduły są niezależnymi blokami, takie problemy jak powyższe nie pojawiają się w ich przypadku. Można bez problemu modyfikować treści funkcji, nawet takich, które znajdują się w pliku interfejsu, i nie martwić się o to, że wymusi to rekompilację całego kodu. Chodzi też o to, że w przypadku programu modułowego bardzo rzadko zachodzi naruszenie reguły jednej definicji (ang. One Definition Rule, w skrócie ODR), czyli że dowolny symbol może być zadeklarowany wiele razy, ale zdefiniowany tylko raz w obrębie całego kodu.

W przypadku nagłówka każde jego dołączenie powoduje automatycznie skopiowanie z niego wszystkich definicji do jednostki kompilacji (czyli np. pliku cpp), która go używa. Ponieważ moduły działają na zupełnie innej zasadzie, informacje z nich brane nigdy nie są powielane. Instrukcja import nie należy do preprocesora, więc moduły nie są przez niego przetwarzane.