C++ Tutorial

Module

Ist eine Anwendung umfangreicher, wird der Programmcode in der Regel auf mehrere Dateien aufgeteilt. Die Aufteilung der Anwendung erfolgt dabei sinnvollerweise so, dass logisch zusammengehörende Funktionen in einem 'Block' zusammengefasst werden. Und jeder dieser 'Blöcke' besteht aus einer Quellcode-Datei mit den Definitionen und einer Header-Datei mit den Deklarationen der nach außen hin sichtbaren Daten und Funktionen.

1: // Datei math1.h
2: // Deklarationen der Mathematik-Funktionen
3: namespace math1
4: {
5:    double sin(double val);
6:    double cos(double val);
7: }
1: // Datei math1.cpp
2: // Definitionen der Mathematik-Funktionen
3: #include "math1.h"
4: double math1::sin(double val)
5: {...}
6: double math1::cos(double val)
7: {...}

Benötigt eine Quellcode-Datei Daten oder Funktionen aus einem 'Block', bindet sie mittels #include die entsprechende Header-Datei ein. Eine Anwendung der Funktionen Sinus() und Cosinus() könnte wie folgt aussehen:

1: // Datei main.cpp
2: #include <iostream>
3: #include "math1.h" // Einbinden der Mathe-Funktionen
4:
5: int main()
6: {
9:     // Cosinus ausgeben
10:    std::cout << math1::cos(3.14) << '\n';
11: }

Eines der Probleme bei diesem Vorgehen ist, dass z.B. bei einer Änderung der Funktionssignatur von sin() oder cos() Anpassungen in den beiden Dateien math1.h und math1.cpp vorgenommen müssen. Um dies zu umgehen, können Module eingesetzt werden. Sie bieten u.a. folgende Vorteile gegenüber der bisherigen Vorgehensweise:

  • Die Aufteilung in Header-Datei und Quellcode-Datei kann entfallen, da ein Modul explizit festlegt, welche Daten und Funktionen nach außen hin sichtbar sind.
  • Eine in Module aufgeteilte Anwendung wird schneller übersetzt, da ein Modul nur einmal übersetzt wird, und dabei die veröffentlichten Symbolen intern in einer Tabelle ablegt werden. Beim Einbinden von Header-Dateien mittels #include dagegen wird in jeder Quellcode-Datei jede eingebundene Header-Datei neu übersetzt.
  • Es gibt (fast) keine Abhängigkeit, in welcher Reihenfolge Module eingebunden werden müssen.

Modul-Definition

Üm eine Quellcode-Datei als Modul-Datei zu definieren, wird die export-Anweisung

export module MNAME;

eingefügt, wobei MNAME den Namen des Moduls festlegt. Der Modulname darf optional einen Punkt enthalten, der keine syntaktische Bedeutung hat und nur dazu dient, Modulnamen lesbarer zu gestalten.

Vor der export-Anweisung dürfen nur Leerzeilen und Kommentare oder die Definition eines globalen Moduls (wird gleich erklärt) stehen. Daraus folgt, dass in einer Datei nur ein Modul definiert werden kann.

Um auf die Daten und Funktionen in einem Modul von außerhalb des Moduls zugreifen zu können, wird vor dem Datentyp des DAtums bzw. vor dem Returntyp der Funktion ebenfalls das Schlüsselwort export gestellt.

Unsere kleine Mathe-Bibliothek könnte damit wie folgt aussehen:

1: // Modul math1 definieren
2: export module math1;
3: // Alle exportierten Funktionen in einen eigenen
4: // Namensraum legen (optional, aber sinnvoll)
5: namespace math1
6: {
7:    export double sin(double val)
8:    {...}
9:    export double cos(double val)
10    {...}
11: }

Einbinden eines Moduls

Um auf die von einem Modul exportierten Daten und Funktionen zuzugreifen, ist das Modul zu importieren.

import MNAME;

wobei MNAME der Name des zu importierenden Moduls ist.

1: #include <print>
2: #include <cmath>
3:
4: import math1; // Symbole aus math1 Modul importieren
5:
6: int main()
7: {
8:     auto val = 3.1416;
9:     // Sinus-Berechung, einmal mithilfe der Standard-
10:    // bibliothek und einmal mit 
11:    // im Modul math1 definierten Funktion
12:    std::println("std:{:6f}, math1:{:.6f}",
13:                  std::sin(val), math1::sin(val));
14: }

Die Angabe von math1 beim Aufruf der Funktion sin() bezieht sich auf den Namensraum in dem die Funktion definiert ist und nicht auf das Modul!

Das Einbinden eines Moduls ist nicht nur auf 'normale' Quellcode-Dateien beschränkt, sondern ein Modul kann ebenfalls ein weiteres Modul importieren.

1: // Modul-Datei any.cxx
2: export module any;    // Export des Moduls
3: import some;          // Modul some wird nicht exportiert!
4: export import other;  // Modul other Import und Export

In Zeile 3 wird das Modul some importiert. Die Daten und Funktionen aus diesem Modul sind nur innerhalb des Moduls any verfügbar. Das Modul other hingegen wird nach dem Import wieder exportiert, sodass dessen Daten und Funktionen ebenfalls beim importieren des Moduls any zur Verfügung stehen.

#include in Modulen (globales Modul)

Da die export-Anweisung die erste Anweisung in einem Modul sein muss, dürfen vor dieser Anweisung keine Header-Dateien mittels #include eingebunden werden. Wird eine Header-Datei nach der export Anweisung eingebunden, wird sie Teil des Moduls, was beim Übersetzen zu recht merkwürdigen Fehlern führt. Was aber tun, wenn in einer Modul-Datei eine Header-Datei benötigt wird? Die Lösung ist die Definition eines globalen Moduls innerhalb des Moduls.

Das globale Modul wird durch die Anweisung

module;

definiert und muss vor der export Anweisung stehen. Nach der Definition des globalen Moduls können die benötigten Header-Dateien eingebunden werden.

1: module;                 // Globales Modul definieren
2: #include <iostream>     // Header-Dateien einbinden
3:
4: export module math1;    // math1 Modul definieren
5: namespace math1 // Namensraum math1
6: {
7:       ... // Daten-/Funktionsdefinitionen
8: }

Aufteilung in Schnittstelle und Implementierung

Bei umfangreichen Modulen kann es sinnvoll sein, die Implementierung von dessen Schnittstelle (zu exportierende Daten und Funktionen) zu trennen. Damit ist es möglich, bei einer Änderung in der Implementierung nur das Modul neu zu übersetzen und nicht die gesamte Anwendung, da sich die Schnittstelle nach außen hin nicht geändert hat.

Um die Schnittstelle von der Implementierung zu trennen, wird das Modul in zwei Dateien aufgeteilt: eine mit der Modul-Schnittstelle und eine mit der Modul-Implementierung.

Die Datei mit der Modul-Implementierung enthält als Erstes die Anweisung module MNAME; (also ohne export). Alle Funktionen des Moduls werden wie 'normale' Funktionen definiert, ohne Berücksichtigung, ob sie exportiert werden oder nicht oder in einem eigenen Namensraum zu liegen kommen.

1: // Datei math1.cxx
2: // Implementierung Modul math1
3: module math1;
4: double sin(double val)
5: {...}
6: double cos(double val)
7: {...}

Erst in der Datei mit der Modul-Schnittstelle werden das Modul und die zu veröffentlichen Funktionen exportiert.

1: // Datei math1.ixx
2: export module math1;
3:
4: namespace math1
5: {
6:    export double sin(double val);
7:    export double cos(double val);
8: }

In der Anwendung ändert sich nichts, d.h das Modul wird weiterhin mittels import math1; importiert.

Modul Partitionen

Wird die Implementierung eines Moduls noch umfangreicher, kann es auf mehrere Dateien aufgeteilt werden, d.h., das Modul wird partitioniert. Ein partitioniertes Modul besteht immer aus einer übergeordneten Modul-Datei sowie den Dateien mit den Modul-Partitionen.

Die Modul-Datei exportiert in der ersten Anweisung das Modul. Die einzelnen Modul-Partitionen werden anschließend mit der Anweisung

[export] import :PNAME

importiert, wobei PNAME der Name der zu importierenden Modul-Partition ist. Soll der Inhalt der importierten Modul-Partition nach außen hin sichtbar sein, ist sie explizit zu exportieren.

1: // Datei greet.cxx
2: export module greet;  // Modul exportieren
3:
4: export import :en;    // Import der Partition en exportieren
5: export import :de;    // Import der Partition de exportieren

In der Partitionsdatei wird die Partition mit der Anweisung

export module MNAME:PNAME

exportiert. Die Anweisung exportiert nur den Namen der Modul-Partition. Alle zu exportierenden Daten und Funktionen müssen zusätzlich gekennzeichnet werden.

1: // Datei de.cxx
2: // mit der Partition de
3: export module greet:de;
4:
5: export namespace language
6: {
7:     const char* Deutsch(void)
8:     {
9:        return "Willkommen!";
10:    }
11: }
1: // Datei en.cxx
2: // mit der Partition en
3: export module greet:en;
4:
5: export namespace language
6: {
7:     const char* Englisch(void)
8:     {
9:        return "Welcome!";
10:    }
11: }

Beachten Sie, dass im Beispiel der Namensraum language exportiert wird. Wird ein Namensraum exportiert, werden alle in ihm definierten Daten und Funktion exportiert.


Copyright 2024 © Wolfgang Schröder
E-Mail mit Fragen oder Kommentaren zu dieser Website an: info@cpp-tutor.de
Impressum & Datenschutz