1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

42%のコンパイル効率向上!C++モジュールに関する実践的分析

Last updated at Posted at 2025-02-13

本記事はこちらのブログを参考にしています。
翻訳にはアリババクラウドの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_

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になることもあります。コンパイラはこれほどの大量のコードに対して字句解析、構文解析、意味解析を行い、対応する最適化を実行し、最終的なコードを生成します。デバッグモードでは、同様のデモコードを次のようにコンパイルします。
2

テスト結果は上記の通りで、約1.2秒かかります。図に示すように、このようなデモコードをコンパイルする際、多くの時間がヘッダーファイルの処理に費やされています。この例では、その時間のほとんどがコンパイラのフロントエンドタスク(プリプロセッシングや字句解析、構文解析、意味解析など)に消費されています。

モジュール版

C++20以降では、モジュール構文を使用して標準ライブラリをインポートできます。cpp
import std;
int main() {
std::cout << "Hello world!" << std::endl;
}

上記のように、import stdだけで標準ライブラリ全体をインポートでき、対応するiostreamモジュールを正確に含める必要はありません。全体の標準ライブラリをインポートしても、コンパイルプロセスは非常に高速です。これは、ヘッダーファイルのコードが事前にコンパイルされているため、モジュールをインポートする際に再度コンパイルする必要がないからです。
3

テストにかかる時間は約0.03秒で、ヘッダーファイルを使用した場合と比べて桁違いの改善が見られます。モジュールの使用により、コンパイル時間が大幅に短縮されます。フロントエンドでの処理時間が相対的に減少しているのは、標準ライブラリの内容をプリプロセスしたり、字句解析、構文解析、意味解析を行う必要がなく、コンパイル済みモジュール成果物を単に逆シリアル化すればよいからです。

2. ヘッダーファイル vs. モジュール

C++ヘッダーファイルには多くの欠点があり、モジュールはこれらの問題を大幅に改善します。

2.1 繰り返しコンパイルによる遅さ

ヘッダーファイルの繰り返しコンパイル

C++ユーザーはしばしばC++コンパイルの遅さを指摘しますが、その理由の一つはヘッダーファイルの繰り返しコンパイルです。次に、C++における繰り返しコンパイルの問題について簡単に説明します。cpp
#include
void split(std::string& str)
{
//...
}
4

上図に示すように、src.cppのようなM個のソースコードファイルがあり、それぞれがstringヘッダーファイルを含んでいるとします。これは一般的なプロジェクトシナリオです。そのため、プロジェクト全体のビルドプロセスでは、このコードをM回プリプロセスおよびコンパイルし、M回のstring関連のアセンブリを生成する必要があります。これが非常に時間のかかる作業です。最悪の場合を考えると、プロジェクトにN個のヘッダーファイルとM個のソースコードファイルがあり、各ソースコードファイルがすべてのN個のヘッダーファイルを含んでいる場合、プロジェクト全体のコンパイル時間の複雑さはO(N*M)になります。これが大規模なC++プロジェクトのビルドが遅くなる理由の一つです。

モジュールによるコンパイルの高速化

5

モジュールが導入されると、モジュールファイル自体がコンパイル単位となり、独立してコンパイルされるため、各ソースコードファイルにプリプロセスされて何度もコンパイルされることはありません。M個のソースコードファイルとN個のモジュールがある場合、コンパイルの時間的複雑さはO(N+M)のみとなります。

2.2 二次コンパイルの遅さ

先ほどは、プロジェクト全体のコンパイルを想定し、ヘッダーファイルのコンパイルが遅くなる理由を分析しました。もう一つの一般的なシナリオは、開発者が少量のコードを修正して再コンパイルする二次コンパイルです(キャッシュが再利用可能)。このシナリオでも、モジュールはコンパイル速度において大きな利点があります。

ヘッダーファイルはコード単位ではなく、独立してコン

モジュールの紹介と構文

1. 潜在的な競合

例えば、ヘッダーファイルAがBで使用されている名前と同じマクロを定義している場合、Bの内容が予期せず破損する可能性があります。典型的な例としては、Windowsプラットフォームでのmaxマクロがstd::max関数に干渉するケースです。モジュールは衛生的(hygienic)であり、外部コードの影響を受けません。
モジュールはプリプロセスを通じて機械的にコードをコピー&ペーストしません。代わりに、各モジュールは独立してコンパイルされるため、モジュールの内容が外部コードの影響を受けないことが保証されます。これがモジュールが衛生的である理由です。

2.4 カプセル化の制御

ヘッダーファイルのカプセル化の欠如

7

ヘッダーファイルにはもう一つの大きな欠点があります。それは、すべての宣言が外部に公開されてしまうことです。例えば、サードパーティライブラリのヘッダーファイルやいくつかの内部実装詳細を含む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(); コンパイルエラー!非エクスポート宣言は使用不可!
}

エクスポートできない項目

特定の項目はエクスポートできません:

  1. マクロ: マクロはプリプロセス段階でのみ存在するため、モジュールはマクロをエクスポートできません。同様に、外部コードのマクロはモジュール内のコードに影響を与えません。
  2. 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 モジュール宣言

モジュールは複数のモジュールユニットで構成され、各モジュールユニットはコードファイルに対応します。ファイルには通常、cppmixxなどの特殊な拡張子が使われます。モジュールユニット内には1つのモジュール宣言しかなく、それがどのモジュールに属するかを示します。cpp
module Foo; // Fooという名前のモジュールを宣言
export module Foo.Bar; // Foo.Barという名前のモジュールを宣言
module Foo.Bar.Gua; // Foo.Bar.Guaという名前のモジュールを宣言

モジュール名における.記号には言語的には特別な意味はありません。Foo.BarとFooの間に階層関係を保証するものではありません。これらの関係は自分で管理する必要があります。

インターフェースユニットと実装ユニット

モジュール宣言の違いに基づき、モジュールユニットは次のように分類できます:

  1. インターフェースユニット: モジュールは1つしかインターフェースユニットを持てず、このユニットでのみexport宣言を使用してインターフェースを外部に公開できます。
  2. 実装ユニット: モジュールは複数の実装ユニットを持つことができ、ここでは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. インターフェースユニット: モジュールは1つのインターフェースユニットしか持つことができず、ここで外部ユーザーに公開するシンボルが宣言されます。
  2. 実装ユニット: モジュールは複数の実装ユニットを持つことができ、ここでコードが実装されます。
  3. パーティションユニット: メインモジュールは複数のパーティションユニットを含むことができますが、これらは独立しておらず、外部にエクスポートすることはできません。これらはメインモジュールの一部です。なお、パーティションユニットもインターフェースユニットと実装ユニットに分けることができ、これらは直交しています(したがって、2*2=4通りのシナリオが存在します)。
  4. ヘッダーユニット: 本質的にはモジュールではなく、シリアル化可能なヘッダーファイルである特別なユニットです。

4. モジュラー化

モジュラー化は多くの変化をもたらします:

モジュールは従来のヘッダーファイルとは異なる組織構造を持っています。モジュール間には依存関係があり、ビルドツールチェーンがモジュール間の依存関係を正しく解析し、正しいビルド順序を得る必要があります。

ヘッダーファイルの互換性と変換

一方で、C++にはヘッダーファイルに基づく膨大なレガシーコードが存在し、最小限のコストでこれらのコードをモジュール化したいと考えています。他方で、リファクタリングされたコードがヘッダーファイルとモジュールの両方と互換性を持つようにしたいと考えています。これにより、スムーズな移行が可能になります。

4.1 モジュールプロジェクトの組織と依存関係

伝統的なC++プロジェクトアーキテクチャ
8

上記の図は、伝統的なC++プロジェクトアーキテクチャを示しています。各翻訳単位(*.cpp)は独立してコンパイルされ、他の単位に影響を与えません。ヘッダーファイルは複数回インクルードされ、コンパイルされます。main.cppがfooとbarの内容を使用しているため、fooとbarのヘッダーファイルをインクルードします。ただし、各cppファイルは依然としてコンパイル中に相互に干渉することはありません。単位は並列にコンパイルでき、相互依存はありません。

モジュールに基づくC++プロジェクトアーキテクチャ

しかし、モジュールコード間には依存関係があります。ある翻訳単位が他の翻訳単位のコンパイル結果に依存することがあります。

9

上記の図は、ヘッダーベースのプロジェクトからモジュールベースのプロジェクトへの移行を示しています。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++プロジェクトをモジュールベースのプロジェクトに変換する実践的な演習を行いました。これにより、モジュールを使用することの利点と便利さを体験しました。モジュールを使用すると、ヘッダーファイルを含める必要がなくなるだけでなく、コンパイル速度も向上します。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?