本記事はこちらのブログを参考にしています。
翻訳にはアリババクラウドのModelStudio(Qwen)を使用しております。
Alibaba Cloud LinuxとC++20モジュールの利点
Alibaba Cloud Linux(またはAlinux)は現在、アリババクラウドで最も広く使用されているオペレーティングシステムです。2021年にはOpenAnolisがAlinux製品に基づいてAnolis OS 8の公式版をリリースしました。本記事では、アリババクラウドインテリジェンスグループの開発エンジニアである李澤政(Zezheng Li)氏が、Alinuxを実行環境として用いて、従来のヘッダーファイルに代わるモジュールの利点について説明します。また、いくつかの例を用いて、C++モジュールプロジェクトの整理方法や、モジュールを使用してサードパーティライブラリをカプセル化したり既存のプロジェクトを変換する方法を示します。さらに、彼はOpenAnolisコミュニティの議長組織であるアリババクラウド内のプロジェクトにおけるモジュールの応用についても紹介します。C++20モジュールコードは、アリババHologresメインラインで1年半以上安定して稼働しており、コンパイル時間を42%削減しています。
1. イントロダクション
モジュールは、コルーチン、レンジ、コンセプトと並んでC++20の4つの重要な機能の1つです。これにより、C++にモジュールの概念が導入され、ユーザーはプロジェクトの整理のためにモジュールをインポートできるようになり、コンパイル速度やカプセル化が大幅に改善されます。Alibaba Cloud Compilerは、アリババクラウドコンパイラーチームによって開発されたC++コンパイラです。これはClang/LLVMコミュニティのオープンソース版に基づいて開発されており、コルーチンとモジュールに対して強力なサポートを提供し、積極的にコードをアップストリームコミュニティに貢献しています。このコンパイラは、Clangによるモジュールサポートの向上に多大な貢献を果たしてきました。
1.1 Hello World! ヘッダーファイル版
まず基本的な比較を見てみましょう。以下はC++のHello Worldコードです。このプログラムは単純に「Hello world」と表示します。cpp
#include
int main() {
std::cout << "Hello world!" << std::endl;
}
従来のヘッダーファイルは、#include
というプリプロセッサ構文を使って記述されています。
コンパイル前には、プリプロセッサがiostream
ファイル全体の内容をコードにコピーします。上記のプログラムはシンプルに見えますが、展開されたコードは最大で3万行、約1MBのサイズに達することがあります(libstdc++でのテスト結果)。もし標準ライブラリ全体を含めると、展開されたコードは10万行を超え、約4MBになることもあります。コンパイラはこれほどの大量のコードに対して字句解析、構文解析、意味解析を行い、対応する最適化を実行し、最終的なコードを生成します。デバッグモードでは、同様のデモコードを次のようにコンパイルします。
テスト結果は上記の通りで、約1.2秒かかります。図に示すように、このようなデモコードをコンパイルする際、多くの時間がヘッダーファイルの処理に費やされています。この例では、その時間のほとんどがコンパイラのフロントエンドタスク(プリプロセッシングや字句解析、構文解析、意味解析など)に消費されています。
モジュール版
C++20以降では、モジュール構文を使用して標準ライブラリをインポートできます。cpp
import std;
int main() {
std::cout << "Hello world!" << std::endl;
}
上記のように、import std
だけで標準ライブラリ全体をインポートでき、対応するiostream
モジュールを正確に含める必要はありません。全体の標準ライブラリをインポートしても、コンパイルプロセスは非常に高速です。これは、ヘッダーファイルのコードが事前にコンパイルされているため、モジュールをインポートする際に再度コンパイルする必要がないからです。
テストにかかる時間は約0.03秒で、ヘッダーファイルを使用した場合と比べて桁違いの改善が見られます。モジュールの使用により、コンパイル時間が大幅に短縮されます。フロントエンドでの処理時間が相対的に減少しているのは、標準ライブラリの内容をプリプロセスしたり、字句解析、構文解析、意味解析を行う必要がなく、コンパイル済みモジュール成果物を単に逆シリアル化すればよいからです。
2. ヘッダーファイル vs. モジュール
C++ヘッダーファイルには多くの欠点があり、モジュールはこれらの問題を大幅に改善します。
2.1 繰り返しコンパイルによる遅さ
ヘッダーファイルの繰り返しコンパイル
C++ユーザーはしばしばC++コンパイルの遅さを指摘しますが、その理由の一つはヘッダーファイルの繰り返しコンパイルです。次に、C++における繰り返しコンパイルの問題について簡単に説明します。cpp
#include
void split(std::string& str)
{
//...
}
上図に示すように、src.cpp
のようなM個のソースコードファイルがあり、それぞれがstring
ヘッダーファイルを含んでいるとします。これは一般的なプロジェクトシナリオです。そのため、プロジェクト全体のビルドプロセスでは、このコードをM回プリプロセスおよびコンパイルし、M回のstring
関連のアセンブリを生成する必要があります。これが非常に時間のかかる作業です。最悪の場合を考えると、プロジェクトにN個のヘッダーファイルとM個のソースコードファイルがあり、各ソースコードファイルがすべてのN個のヘッダーファイルを含んでいる場合、プロジェクト全体のコンパイル時間の複雑さはO(N*M)になります。これが大規模なC++プロジェクトのビルドが遅くなる理由の一つです。
モジュールによるコンパイルの高速化
モジュールが導入されると、モジュールファイル自体がコンパイル単位となり、独立してコンパイルされるため、各ソースコードファイルにプリプロセスされて何度もコンパイルされることはありません。M個のソースコードファイルとN個のモジュールがある場合、コンパイルの時間的複雑さはO(N+M)のみとなります。
2.2 二次コンパイルの遅さ
先ほどは、プロジェクト全体のコンパイルを想定し、ヘッダーファイルのコンパイルが遅くなる理由を分析しました。もう一つの一般的なシナリオは、開発者が少量のコードを修正して再コンパイルする二次コンパイルです(キャッシュが再利用可能)。このシナリオでも、モジュールはコンパイル速度において大きな利点があります。
ヘッダーファイルはコード単位ではなく、独立してコン
モジュールの紹介と構文
1. 潜在的な競合
例えば、ヘッダーファイルAがBで使用されている名前と同じマクロを定義している場合、Bの内容が予期せず破損する可能性があります。典型的な例としては、Windowsプラットフォームでのmax
マクロがstd::max
関数に干渉するケースです。モジュールは衛生的(hygienic)であり、外部コードの影響を受けません。
モジュールはプリプロセスを通じて機械的にコードをコピー&ペーストしません。代わりに、各モジュールは独立してコンパイルされるため、モジュールの内容が外部コードの影響を受けないことが保証されます。これがモジュールが衛生的である理由です。
2.4 カプセル化の制御
ヘッダーファイルのカプセル化の欠如
ヘッダーファイルにはもう一つの大きな欠点があります。それは、すべての宣言が外部に公開されてしまうことです。例えば、サードパーティライブラリのヘッダーファイルやいくつかの内部実装詳細を含むmylib
ヘッダーファイルがあるとします。これらの詳細をユーザーに公開したくない場合もあるでしょう。しかし、ヘッダーファイルでは、これらのシンボルはユーザーに対して可視であり、どのシンボルが公開されるかを制御することはできません。その結果、ユーザーのコードに影響を与えたり、意図しないインターフェースを使用してしまう可能性があります。
モジュールによる強力なカプセル化
モジュールでは、export
キーワードを使用して外部に公開すべき宣言を指定できます。export
キーワードが付与されていない宣言はデフォルトで外部ユーザーから隠され、この問題を解決します。
3. モジュール構文の紹介
このセクションでは、モジュールによって導入された新しい宣言構文を簡単に説明し、例を通じてこれらの構文の使い方を示します。
3.1 Export宣言
export
は可視性を制御します。
モジュールはexport
キーワードを取り戻し、export
キーワードでマークされた宣言はモジュール外の外部コードで使用するためにエクスポートされます。export
キーワードがない宣言は外部ユーザーには見えず、使用できません。cpp
// Hello.cppm
export inline int a; // 変数をエクスポート
export void foo(); // 関数宣言をエクスポート
void bar(); // 宣言はエクスポートされない
export void foo(){…} // 関数実装をエクスポート
export class A {}; // クラスをエクスポート
export enum B {}; // 列挙型をエクスポート
export namespace my_lib {}; // 名前空間をエクスポート
export template C{}; // テンプレートをエクスポート
export using std::max; // using宣言をエクスポート
export using D=std::vector; // エイリアスをエクスポート
// main.cpp
import Hello;
int main() {
foo(); // モジュールからのエクスポートされた宣言を使用
// bar(); コンパイルエラー!非エクスポート宣言は使用不可!
}
エクスポートできない項目
特定の項目はエクスポートできません:
- マクロ: マクロはプリプロセス段階でのみ存在するため、モジュールはマクロをエクスポートできません。同様に、外部コードのマクロはモジュール内のコードに影響を与えません。
-
using namespace宣言:
export using namespace std;
のような宣言をエクスポートすることは推奨されません。これにより外部コードの期待される動作が簡単に壊れる可能性があります。そのため、C++モジュールではこのような宣言のエクスポートを禁止しています。(もちろん、export
キーワードなしで内部的に使用することはでき、外部コードには影響しません。)
非表示の宣言の使用
興味深いことに、以下のように、状況によっては間接的に非表示の宣言を使用することができます:cpp
// Hello.cppm
struct my_string {
//...
};
export my_string hello();
export void hi(const my_string&);
// main.cpp
import hello;
int main() {
// my_string str = hello(); このように書くことはできません!my_stringは非表示です。
auto str = hello(); // ただし、型の自動推論を介して匿名で非表示の型を使用できます。
hi(str);
}
3.2 モジュール宣言
モジュールは複数のモジュールユニットで構成され、各モジュールユニットはコードファイルに対応します。ファイルには通常、cppm
やixx
などの特殊な拡張子が使われます。モジュールユニット内には1つのモジュール宣言しかなく、それがどのモジュールに属するかを示します。cpp
module Foo; // Fooという名前のモジュールを宣言
export module Foo.Bar; // Foo.Barという名前のモジュールを宣言
module Foo.Bar.Gua; // Foo.Bar.Guaという名前のモジュールを宣言
モジュール名における.
記号には言語的には特別な意味はありません。Foo.BarとFooの間に階層関係を保証するものではありません。これらの関係は自分で管理する必要があります。
インターフェースユニットと実装ユニット
モジュール宣言の違いに基づき、モジュールユニットは次のように分類できます:
-
インターフェースユニット: モジュールは1つしかインターフェースユニットを持てず、このユニットでのみ
export
宣言を使用してインターフェースを外部に公開できます。 -
実装ユニット: モジュールは複数の実装ユニットを持つことができ、ここでは
export
宣言を使用できません。cpp
// Foo.cppm
export module Foo; // インターフェースユニット
// Foo_impl1.cpp
module Foo; // 実装ユニット
// Foo_impl2.cpp
module Foo; // 実装ユニット
一般的に、従来のヘッダーベースのプロジェクトのようにモジュール内で宣言と実装を分離する必要はありません。ただし、アセンブリなどでは、ビルドツールを使って異なるプラットフォームで異なる実装を選択するために、宣言と実装を分離したい場合もあります。インターフェースユニットと実装ユニットを使用して、伝統的なC++プロジェクトにおける宣言と実装の分離をシミュレートすることも可能です。
モジュールパーティション
モジュールを複数のパーティションに分割できます。ただし、パーティションは独立したモジュールではなく、単独では使用できません。cpp
export module Foo.Bar:part1; // Foo.Barモジュールに属するパーティションを宣言
export module Foo.bar:part1:part2; // 無効!パーティションはネストできません!
module Foo.Bar:part1; // パーティションにもインターフェースユニットと実装ユニットの区別があります。
3.3 Import宣言
次に、モジュールのimport
宣言を紹介します。これにより他のモジュールをインポートできます。cpp
// main.cpp
import std; // 標準ライブラリモジュールをインポート
import foo; // foo.barモジュールをインポート
import foo.bar:part1; // 無効!別のモジュールのパーティションモジュールをインポートできません。
// foo.cppm
export import foo.bar; // foo.barモジュールをインポートし、外部ユーザーに再エクスポート
import std; // stdモジュールをインポートしますが、外部ユーザーには公開されません。
// foo.bar.cppm
export import :part1; // stdモジュールをインポートしますが、外部ユーザーには公開されません。
モジュールコードの基本構造
Export宣言、Module宣言、Import宣言を紹介した後、最終的に完全なモジュールコードの例を提示し、その基本構造を分析します。cpp
/*
しかし、Googleはこの設計に重大な欠陥があることを発見しました。それは、マクロをエクスポートできないことです。そのため、Googleは解決策として「Header Unit」を提案しました。純粋な互換性の目的で、Header Unitの効果は#includeヘッダーファイルとまったく同じです(コードはマクロを導入し、マクロの影響を受けます)。しかし、これはコンパイラがHeader Unitに対して限られた前処理しか行えないことを意味し、コンパイル速度の向上が大幅に制限されることになります。cpp
import ;
// 効果は#include とまったく同じです。
// マクロが導入され、iostream内のコードもマクロの影響を受ける可能性があります。
int main()
{
std::cout<<Hello world<<std::endl;
}
3.5 まとめ
以下はモジュール単位の種類のまとめです:
- インターフェースユニット: モジュールは1つのインターフェースユニットしか持つことができず、ここで外部ユーザーに公開するシンボルが宣言されます。
- 実装ユニット: モジュールは複数の実装ユニットを持つことができ、ここでコードが実装されます。
- パーティションユニット: メインモジュールは複数のパーティションユニットを含むことができますが、これらは独立しておらず、外部にエクスポートすることはできません。これらはメインモジュールの一部です。なお、パーティションユニットもインターフェースユニットと実装ユニットに分けることができ、これらは直交しています(したがって、2*2=4通りのシナリオが存在します)。
- ヘッダーユニット: 本質的にはモジュールではなく、シリアル化可能なヘッダーファイルである特別なユニットです。
4. モジュラー化
モジュラー化は多くの変化をもたらします:
モジュールは従来のヘッダーファイルとは異なる組織構造を持っています。モジュール間には依存関係があり、ビルドツールチェーンがモジュール間の依存関係を正しく解析し、正しいビルド順序を得る必要があります。
ヘッダーファイルの互換性と変換
一方で、C++にはヘッダーファイルに基づく膨大なレガシーコードが存在し、最小限のコストでこれらのコードをモジュール化したいと考えています。他方で、リファクタリングされたコードがヘッダーファイルとモジュールの両方と互換性を持つようにしたいと考えています。これにより、スムーズな移行が可能になります。
4.1 モジュールプロジェクトの組織と依存関係
上記の図は、伝統的なC++プロジェクトアーキテクチャを示しています。各翻訳単位(*.cpp)は独立してコンパイルされ、他の単位に影響を与えません。ヘッダーファイルは複数回インクルードされ、コンパイルされます。main.cppがfooとbarの内容を使用しているため、fooとbarのヘッダーファイルをインクルードします。ただし、各cppファイルは依然としてコンパイル中に相互に干渉することはありません。単位は並列にコンパイルでき、相互依存はありません。
モジュールに基づくC++プロジェクトアーキテクチャ
しかし、モジュールコード間には依存関係があります。ある翻訳単位が他の翻訳単位のコンパイル結果に依存することがあります。
上記の図は、ヘッダーベースのプロジェクトからモジュールベースのプロジェクトへの移行を示しています。main.cppがfooとbarの内容を使用しているため、各モジュールを事前にコンパイルする必要があり、直接インクルードすることはできません。したがって、fooとbarモジュールを先にコンパイルし、コンパイラのフロントエンド処理を完了してから、main.cppのコンパイルを開始する必要があります。モジュールが導入された後、コード単位間に依存関係が生じ、これがC++プロジェクトにおける最大の変化です。
4.2 モジュールラッパー
モジュールラッパーは、古いスタイルのヘッダーファイルをシンプルなモジュールカプセル化層を通じて標準のC++モジュールファイルに変換することを目的とした特殊な技術です。これにより、両者の互換性を実現します。
export using 宣言
この技術の1つのアプローチは、export using
宣言を使用することです。既存のヘッダーファイルを迅速にモジュールにカプセル化でき、古いコードに影響を与えません。cpp
// iostream.cppm
module;
#include
export module iostream;
namespace std {
export using std::cin;
export using std::cout;
export using std::endl;
}
// main.cpp
import iostream;
int main() {
std::cout<<Hello Module Wrapper<<std::endl;
}
上記のコードでは、グローバルモジュールフラグメントに標準ライブラリのヘッダーファイルを含め、その後 export using
宣言を使用してこれらの宣言をエクスポートすることで、シンプルな標準ライブラリモジュールを作成しました。この技術を通じて、ヘッダーファイル自体を変更せずに、簡単な中間層を導入することで既存のヘッダーファイルコードをモジュールにカプセル化できます。多くのモジュール化された標準ライブラリはこの方法で実装されています。例えば、async_simple ライブラリ はこの技術を使用して標準ライブラリモジュールの実装をシンプルにカプセル化しています。
export extern c++
もう1つのカプセル化のアプローチは、export extern c++
とモジュール制御マクロを使用することです。このアプローチでは、すべてのシンボルを手動で列挙することなく、一度にヘッダーファイル内のシンボルをエクスポートできます。このアプローチは、ヘッダーベースおよびモジュールベースのコードとの互換性を確保します。例えば、fmt ライブラリはこのアプローチを使用してヘッダーとモジュールの両方をサポートしています。cpp
// hello.hpp
#ifdef HELLO_USE_MODULE // このマクロを制御してモジュールを使用するかどうかを決定します。
import std;
#else
#include
#include
#endif
void hello() {
// ...
}
// hello.cppm
module;
export module hello;
export extern C++ {
#define HELLO_USE_MODULE
#include
}
まず、元のヘッダーファイルを修正します。hello.hpp
では、制御マクロ HELLO_USE_MODULE
を使用してモジュールかヘッダーファイルかを選択します。次に、hello
モジュールのインターフェースファイルでは、export extern c++
構文を使用して対応するヘッダーファイルをコードセグメントにインクルードし、対応するマクロを有効にすることで、hello.hpp
のコード全体をモジュール内に取り込み、外部にエクスポートします。
4.3 モジュールリファクタリングツール
前述のモジュールリファクタリングプロセスには繰り返し作業が含まれています。作業負荷を軽減するために、いくつかの自動化ツールを試験的に提供しています。これらのツールは将来的にClangメインラインに統合される可能性があります。ツール
Alibaba Cloud ECSユーザー向けの推奨設定とワークショップガイド
Alibaba Cloud ECSユーザーには、Alibaba Cloud Linux 3 システムを使用し、yum install -y alibaba-cloud-compiler
コマンドを実行して Alibaba Cloud Compiler をインストールすることをお勧めします。また、Xmake ビルドツールをインストールしてください。詳細はこちらをご覧ください: https://xmake.io/#/getting_started
GitHubからワークショップの手順をダウンロードしてください。
WorkShop 1: GoodBye Head File
リンクはこちらです:
https://github.com/poor-circle/workshop/blob/master/work1/任务说明.md
WorkShop 2: Hello world, C++ modules
リンクはこちらです:
https://github.com/poor-circle/workshop/blob/master/work2/任务说明.md
WorkShop 3: Write a single module
リンクはこちらです:
https://github.com/poor-circle/workshop/blob/master/work3/任务说明.md
WorkShop 4: Write multiple module units
リンクはこちらです:
https://github.com/poor-circle/workshop/blob/master/work4/任务说明.md
WorkShop 5: Convert a traditional header project to a module
リンクはこちらです:
https://github.com/poor-circle/workshop/blob/master/work5/任务说明.md
2024年の OpenAnolis Conference で、著者は技術共有ワークショップに参加するよう招待されました。このワークショップでは、参加者を指導し、Anolis OS 上で Alibaba Cloud Compiler を使用して、従来のC++プロジェクトをモジュールベースのプロジェクトに変換する実践的な演習を行いました。これにより、モジュールを使用することの利点と便利さを体験しました。モジュールを使用すると、ヘッダーファイルを含める必要がなくなるだけでなく、コンパイル速度も向上します。