2020.11.18こんにちは。情熱開発部プログラム課の安田です。Studioはじめ、各種C++コンパイラのC++20対応が着々と進む中、CEDEC2020でC++20の機能を紹介する講演が実施されるなど、ゲーム業界でもC++20導入の機運が高まっています。今回はC++20の新機能の中でも、個人的に特に気になっていた「モジュール」について紹介します。インクルードに代わる新たなプログラム分割の仕組みのことです。cpprefjpによると、C++にモジュールが必要になったのは、以下のような背景があるようです。プリプロセッサによるインクルードは、ヘッダーファイルの内容をその場に展開する。これには次のような問題が指摘されてきた。プリプロセッサの状態により、インクルードの結果が変わってしまうインクルードの順番によってエラーが起きることがあった。ヘッダーファイル内の記述の影響を受けすぎる影響が大きいため、ヘッダーファイル内に書くことがためらわれる記述があった。namespaceやマクロ(例えばWindowsにおけるmax)など。モジュールは、以上のような問題のないプログラム分割の仕組みとして導入された。https://cpprefjp.github.io/lang/cpp20/modules.html専門用語が多くて難しい......しかし、モジュールの利点を正しく把握していなければ、慣れ親しんだインクルードを捨てて、わざわざモジュールに移行しようという気にはなれません。いうわけでここから基本に立ち返って、ビルドの手順を振り返りながら、旧来のインクルード(#include)が実際に何をしているのか詳しくみていきます。その後、改めてモジュールの利点について考えてみます。C++のビルドの仕組みインクルードの挙動を理解するには、C++のビルドの仕組みについて把握していなければなりません。ここでいうビルドとは、C++のソースコードを、実行ファイルやライブラリに変換する処理のことを指します。ここではビルドの処理について、下図を元に説明します。これらの説明はこちらのサイトを参考に記述しています。ビルドは、プリプロセッサ、コンパイラ、アセンブラ、リンカという4つの手続きからなります。Cプリプロセッサとも)とは、ソースコードをアセンブラに変換する前に、テキストを変形する前処理のことです。プリプロセッサは主に以下の処理を実行します。#includeや#defineなどがあります。#includeとはプリプロセッシングディレクティブの一つだったのです。#includeディレクティブは、指定したファイルの中身をその場に展開します。ようはそのファイルの中身をコピペしているだけです。#defineディレクティブは、トークンを別のトークンに置換する役割を持ちます。記述した時、それ以降の行で「INF」という文字が現れれば、それは全て「100000000」に置き換わります。コンパイラプリプロセッサで前処理を施したソースファイルは、一つずつコンパイラに渡され、コンパイルされます。コンパイルにより、ソースファイルはアセンブリに変換されます。アセンブリとは、機械語を人間が理解しやすい形に書き下した、低水準プログラミング言語です。「コンパイル」という言葉は、プリプロセッサからリンクまでの処理を指すことがあります。ここでは、プリプロセッサからリンクまでの一連の処理のことを「ビルド」、コンパイラが実行する処理のことを「コンパイル」と区別して呼ぶこととします。アセンブラアセンブラは、コンパイラが生成したアセンブリファイルを、マシンが解読できる形、つまり機械語に翻訳する役割を持っています。C++では、アセンブラが生成する機械語ファイルのことをオブジェクトファイルとも呼びます。オブジェクトファイルはバイナリ(2進数)、つまり0と1だけで表現された命令の羅列であり、コンピュータはこの命令に従って動作します。リンカリンカは、作成された複数のオブジェクトファイルを一つにまとめる役割を持ちます。この図の例では、一つのソースファイル(main.cpp)でプログラムが完結しているので、リンカは特に何もしません。リンカの処理が終わると、晴れてビルドが完了し、実行可能なファイルが生成されます。複数ファイルからなるプログラムのビルド#includeはプリプロセッサを使ってプログラムを複数ファイルに分割するための仕組みです。実際に複数ファイルからなるプログラムをビルドして、プリプロセッサ、コンパイラなどの挙動を確認してみます。今回ビルドするプログラムは以下の3つのファイルからなります。LTS上で動作するgccコンパイラを使いました。ビルドの流れ前章と同様に、このプログラムのビルドの流れを図示してみました。プリプロセッサ本来、プリプロセッサ処理はコンパイル時に暗黙的に実行されます。オプションで、プリプロセッサによる処理を終えた後のソースファイルを確認できます。記述した行に、sample.hppの中身がそのまま展開されているのが分かります。コンパイラコンパイラは、プリプロセッサが処理した1つのソースファイルを、1つの翻訳単位として扱いコンパイルします。コンパイラは、sample.cppとmain.cppを別々にコンパイルします。オプションで、コンパイラが出力するアセンブラを確認できます。@functionmain:(以下省略)ここで、改めてプリプロセッサが処理したmain.cppの中身(#から始まる行は無視)を見てみます。0;}この時点でmain.cppからは、add関数の中身の実装が見えないので、普通ならadd関数を使用する時点でエラーが出るはずです。関わらずコンパイルに成功します。これは、コンパイラがadd関数について「int型の引数を2つ取るaddという名前の関数がある」という情報さえあれば、その中身は後で解決すればよいとしているからです。この情報の事をシンボルと呼びます。C++コンパイラは関数が宣言されると、それに対応するシンボルを生成します。同じシンボルが生成されると、コンパイルは通りません。C++では、シンボルだけ生成され、対応する関数の実装がまだ決まっていない状態のことを、外部シンボル(参照)が未解決であるといいます。アセンブラアセンブラは、コンパイラが生成したアセンブラファイルを解釈して、オブジェクトファイルを生成します。オプションで、渡したソースファイルをオブジェクトファイルに変換できます。main.oなおLinuxでは、nmコマンドを使って、オブジェクトファイル中に埋め込まれたシンボルを取得することが出来ます。main.oの出力を見ると、add関数がU(未定義、Undefined)状態であることがわかります。ちなみにC言語のコンパイラは、シンボルに関数名の情報しか載せません。そのためC言語には関数オーバーロードがないのです。C++コンパイラは、シンボルに関数名に加えて、「返り値の型」と「引数の型」の情報も載せます。この処理のことを名前マングリングと呼ぶそうです。C++は名前マングリングによって関数のオーバーロードを実現しています。リンカリンカでは、アセンブラが生成した2つのオブジェクトファイルを束ねて、一つの実行可能ファイルを生成します。リンク後も、外部シンボルが未解決状態のもの、すなわち宣言はあるのに実装がないものがあれば、この時点でエラーになります。外部ライブラリを使用したい時、そのライブラリのソースコードが全て公開されていれば、今回示した手順で正しくビルドできます。常に全てのソースコードが公開されるわけではありません。ライブラリ製作者が作成したオブジェクトファイル(とヘッダ)があればそれを自分が書いたコードにリンクすることで、外部ライブラリを使用できます。外部ライブラリを使用するこの仕組みは、オブジェクトファイルをリンクするタイミングによって、静的リンク・動的リンクと呼び分けられます。#includeを使う上での注意点ここまでC++のビルドの手順について説明しました。再度の説明になりますが、#includeは指定したファイルの中身をその場に展開(コピペ)しているだけです。やろうと思えば下のような書き方だって出来ます。0;}ここから、私が知る限りの#includeを使用上の注意点を紹介します。#include<>と#include””の違い#include<hoge>と#include”hoge”は、どのディレクトリを検索するかで違いがあります。#include<hoge>は、処理系指定のインクルードパスからのみファイルを検索します。#include”hoge”は、(一般的には)まずカレントディレクトリから対象のファイルがないか検索し、もし見つからなければ、処理系指定のインクルードパスを検索します。このファイルから見て./include/math以下にadd.hppがあればOKg++では、-Iオプションでインクルードパスを追加できます。ファイルを編集すればインクルードパスを追加できます。「追加のインクルードディレクトリ」を編集します。Rule、単一定義原則)と呼ぶ)。複数回定義されているため、コンパイルに失敗します。{}C++では、慣例的にヘッダファイルで関数(クラス)を宣言し、ソースファイルでそれをインクルードして実装するというスタイルが取られます。}しかし、わざわざファイルを分けて定義するのが面倒な関数は、ヘッダファイルに関数の実装を書くことがあります。^この問題に対応するために、C++にはインクルードガードの仕組みがあります。{};#endifこう記述したヘッダファイルは、2回目以降のインクルードがプリプロセッサ上で無視され、コンパイル対象にならないので、エラーが出なくなります。使って、2重インクルードを避ける方法もあります。コンパイル時間が長くなりがち#includeの多用は、コンパイル時間の肥大化に繋がります。ここまで何度も述べたとおり、プリプロセッサによるインクルードはヘッダファイルの中身をその場に展開します。worldだけでも、長大な<iostream>ヘッダをその場に展開するため、プログラムが長くなってしまいます。インクルードされるヘッダファイルに書くべきでない記述があるインクルードされるヘッダファイルにusing指令やusing宣言を書くと予期せぬ名前衝突が起こり危険です。stdは避けるべきです。std::stringと名前衝突しコンパイルエラー};どうしてもヘッダファイル内でusing指令やusing宣言を使いたい時はブロックスコープ内で使用すると良いでしょう。std::stringは別スコープにあるのでOK};同様に、不要な名前衝突を避けるために、必要なければヘッダファイル内でマクロを使うのも極力避けるべきです。インクルード順を変えるとエラーが発生することがあるごく稀にインクルード順を変えるとエラーが発生することがあります。これもプリプロセッサでインクルードするヘッダファイルの中身をそのまま展開する故の問題です。以下にその簡単な例を示します。std::endl;}hoge.hpp、fuga.hppの順でインクルードするとmain.cppのコンパイルは通ります。fuga.hpp、hoge.hppの順でインクルードするとmain.cppのコンパイルに失敗します。モジュールの利点4章では#includeを使う上で知っておきたいこと、そしてその問題点について述べました。ここまで理解できると、C++20のモジュールの利点が何となく見えてきます。一度cpprefjp記載の「モジュールが必要になった背景・経緯」を見てみましょう。プリプロセッサの状態により、インクルードの結果が変わってしまうインクルードの順番によってエラーが起きることがあった。ヘッダーファイル内の記述の影響を受けすぎる影響が大きいため、ヘッダーファイル内に書くことがためらわれる記述があった。namespaceやマクロ(例えばWindowsにおけるmax)など。https://cpprefjp.github.io/lang/cpp20/modules.htmlモジュールはプリプロセッサを使わずにプログラムを分割出来ます。モジュールは、#includeのようにプリプロセッサでヘッダを展開して依存関係を解決するのではなく、コンパイル時に自動で依存関係を解決してくれるのです。これにより#includeの問題は以下のように解消されます。コンパイル時間が長くなる翻訳単位ごとにヘッダファイルを展開しないので、語句解析が早くなる。その結果、一般的にモジュールを使ったほうがコンパイル時間が短くなる。namespaceやマクロが、そのモジュールを使用するソースファイルに漏れなくなる。これにより、プリプロセッサの処理を追わないと容易に発見できなかったバグが発生しなくなる。先程モジュールは#includeと比べてコンパイル時間が短くなると話しましたが、逆にモジュールのほうが#includeよりコンパイル時間が長くなる場合があるようです。モジュールと#includeのベンチマーク計測結果が報告されています。以下に、P1441R1中で示されたモジュールと#includeのベンチマーク計測結果を比較したグラフを示します。ジョブ数64の時の#includeとモジュールのビルド時間比較グラフ1を見ると、ジョブ数(並列数)が少ない時、モジュールは常に#includeよりも短い時間(最速で#includeの1/4の時間)でビルドできることが分かります。グラフ2では、ジョブ数(並列数)が64と大きい時、モジュールは依存関係が複雑になると、#includeよりビルドに時間がかかることが示されています。並列数を増やすと逆にパフォーマンスが劣化するというモジュールの特性は、モジュールが依存順にコンパイルしなければならず、並列化しにくいことに由来します。並列数がそこまで多くない一般的なPCでビルドする分には、モジュールのほうが早いと考えていいと思われます。#includeとモジュールの比較まとめ以下に、ここまで示してきた#includeとモジュールの差異を表にまとめました。モジュールを試してみるようやくモジュールの利点について分かったところで、実際にモジュールを使ってみました。2019のPreview版を使用しました。実装C++プロジェクトを作成したら、モジュールが使えるように、オプションからC++の言語標準をC++14からプレビュー(/std:c++latest)に変更してください。モジュールを作成する時は、新しい項目の追加から「C++モジュールインターフェイスユニット(*.ixx)」を選択してください。以下にモジュールを使ったコードの例を示します。ここではモジュールの細かい文法については述べません。Blogの記事など既存の資料をご確認ください。Studioでモジュールを使ってみたところ、まだ実用するのは厳しいかなと感じました。InteliSenceが言うことをきかず、暫く待たないとモジュールを認識しないということがままありました。#includeと共存せざるを得ないので、その取り扱いにも苦労しました(なお今回は試していませんが、C++23以降でSTLのモジュール化が検討されているようです)。加えて、C++で使われるC言語に対応したヘッダファイルは、今後もモジュール化されることはないと考えられます。モジュールには後方互換性のための機能がいくつか用意されていますが、今後全てをモジュールで記述できるようになるのは難しいのではと思われます。おわりに長くなりましたが、今回はC++20で導入されるモジュールについて、詳しく説明させていただきました。Studioでモジュールを使ってみた」で終わるつもりだったのですが、気づいたらビルドの手順まで説明することになってしまいました。これまでIDEに任せきりで裏で実際に何をやっているのかよく分かっていなかった、ビルドの流れについて学べて非常に勉強になりました。モジュールの他にも様々な便利機能が追加されているC++20。C++erの方は是非使ってみてください。本サイトでの情報を利用することによる損害等に対し、株式会社ロジカルビートは一切の責任を負いません。understrap.com。