🍇

[C++] モジュールへの移行を考える 1 - 単一ヘッダファイル+単一ソースファイル

2020/11/14に公開

C++20ではモジュールが導入され、C++は長年欠いていたプログラムの分割と構成の効率的な手段を手に入れました。MSVCは既に実装を完了しておりGCC11も来年春に使用できるようになるかもしれないなど、モジュールを利用可能な環境は着実に整いつつあります。
ところが、モジュールは複雑に規定されており、そこそこ分量がある上にあっちこっちに散らばっていて、規格書をちょっと読んだだけではなんだかよく分かりません。

この一連の記事は面倒な聖典の解釈作業を避けてモジュールを使用してもらうために、今まで当たり前に書いていたコードはモジュールによってどのように書くの?という疑問を解消するための一助となれたらいいなあというものです。

目次(予定)

  1. 単一ヘッダファイル+単一ソースファイル(この記事)
  2. 実装の隠蔽について
  3. 単一ヘッダファイル+複数ソースファイル
  4. 複数ヘッダファイル+単一ソースファイル
  5. 複数ヘッダファイル+複数ソースファイル
  6. ヘッダオンリー
  7. モジュールとヘッダファイルの同時提供

単一ヘッダファイル+単一ソースファイル

まず最初は最も普通な構成と思われる、ヘッダとソースが1つづつある場合のプログラムの移行について見てみます。

/// mylib.h

namespace mylib {

  class S {
    int m_num = 0;
  public:

    S();
    S(int n);

    int get() const;
  };

  void print_S(const S& s);
}
/// mylib.cpp

#include <iostream>
#include "mylib.h"

namespace mylib {

  S::S() = default;
  S::S(int n) : m_num{n} {}

  inline int S::get() const {
    return this->m_num;
  }

  void print_S(const S& s) {
    std::cout << s.get() << std::endl;
  }
}

このような構成のファイルは一対一でモジュールインターフェース単位とモジュール実装単位が対応します。

まずヘッダファイルはモジュールインターフェース単位に対応し、次のように書くことになります。。

/// mylib.ixx

export module mylib;  // mulibモジュールのインターフェース単位の宣言

namespace mylib {

  // クラスのエクスポート、暗黙に全メンバがエクスポートされる
  export class S {
    int m_num = 0;
  public:

    S();
    S(int n);

    int get() const;
  };

  // フリー関数のエクスポート
  export void print_S(const S& s);
}

モジュールインターフェース単位であることは、ファイル先頭のexport module mylib;によって宣言します(これをモジュール宣言と呼びます)。以降このファイルはmylibモジュールのインターフェースとして扱われます(ファイル拡張子は実装によって変わります。.ixxはMSVCの指定する拡張子です)。
mylibの部分はモジュールの名前であり、.が使用可能なこと以外は名前空間の名前とほぼ同様の規則の下で自由な名前を付けることができます。

モジュールのインターフェースにはヘッダファイルと同様に外部に公開したいものの宣言を書きます。exportをその先頭に付加することで公開したい宣言を明示します。

次に、ソースファイルはモジュール実装単位に置き換えられます。

/// mylib.cpp

module; // グローバルモジュールフラグメント

// #includeはグローバルモジュールフラグメント内で行う
#include <iostream>

module mylib;  // mylibモジュールの実装単位の宣言
// mylibモジュールのインターフェースを暗黙にインポートしている

// ここから下は書き換える必要が無い
namespace mylib {

  S::S() = default;
  S::S(int n) : m_num{n} {}

  inline int S::get() const {
    return this->m_num;
  }

  void print_S(const S& s) {
    std::cout << s.get() << std::endl;
  }
}

モジュール実装単位であることは、ファイル先頭のmodule mylib;によって宣言します(これもモジュール宣言と呼びます)。このとき、モジュール名は先程のインターフェースを作った時と同じ名前にします。このモジュール宣言以降、このファイルはmylibモジュールの実装単位として扱われます。
この例のように、従来のヘッダファイルのインクルードが必要となるときはグローバルモジュールフラグメントを利用します。モジュール宣言のさらに前でmodule;と書いておくことでそこからモジュール宣言までの間の領域がグローバルモジュールフラグメントとして扱われます。

モジュール実装単位は対応するインターフェース単位を暗黙にインポートしています。すなわち、先程別ファイルに書いたmylibモジュールのインターフェース単位内のものを(ここでは前方宣言として)参照することができます。

このようにモジュールへの移行自体はとても簡単で、モジュール宣言をファイル先頭で行って公開する宣言にexportを付ける以外は、ほぼ従来通りに書くことができます。

プライベートモジュールフラグメント

ヘッダもソースもそれぞれ1つしかない場合、わざわざファイルを分けずに1ファイルでモジュールを完結させたいことが多いと思われます。そのような場合に、プライベートモジュールフラグメントを利用できます。

プライベートモジュールフラグメントを使用すると、先程モジュールとして定義した2つのインターフェース単位と実装単位は次のようにまとめられます。

/// mylib.ixx

module; // グローバルモジュールフラグメント

// #includeはグローバルモジュールフラグメント内で行う
#include <iostream>

export module mylib;
// ここからはインターフェース

namespace mylib {

  // クラスのエクスポート、暗黙に全メンバがエクスポートされる
  export class S {
    int m_num = 0;
  public:

    S();
    S(int n);

    int get() const;
  };

  // フリー関数のエクスポート
  export void print_S(const S& s);
}

// インターフェースはここまで
private : module;
// ここからが実装

namespace mylib {

  S::S() = default;
  S::S(int n) : m_num{n} {}

  inline int S::get() const {
    return this->m_num;
  }

  void print_S(const S& s) {
    std::cout << s.get() << std::endl;
  }
}

ファイル途中のprivate : module;というのが、プライベートモジュールフラグメントであり、そこを境界としてインターフェースと実装を1つのファイル内で分割します。すなわち、private : module;よりも前の領域がインターフェース単位に、後ろの領域が実装単位に対応しています。
プライベートモジュールフラグメントの実装部分ではグローバルモジュールフラグメントを作れないので、グローバルモジュールフラグメントはファイルの先頭に移動することになりますが、それ以外は2つのファイルでモジュール化したときと同様に書くことができます。

なお、プライベートモジュールフラグメント内ではexportを伴う宣言を行うことはできず、行った場合コンパイルエラーになります。

正確な用語では、プライベートモジュールフラグメントというのはprivate : module;←これの事ではなく、それを使用したファイルあるいはモジュールの事を指すのでもなく、private : module;の直後からそのファイル終端までの実装部分の領域の事を指しています。

利用側

普通の方法とプライベートモジュールフラグメントによる方法のどちらを用いたとしてもモジュールを利用する側からはそれを観測することはできず、同じように利用できます。

import mylib; // mylibモジュールのインポート宣言

int main() {
  mylib::S s1{};
  mylib::S s2{20};
  
  mylib::print_S(s1); // 0
  mylib::print_S(s2); // 20
}

モジュールの使用はインポート宣言によって行い、importに続いてモジュール名を指定します(セミコロンは必須です)。モジュール名はモジュールを作るときのモジュール宣言に指定した名前と同じ名前を指定します。

そしてこの時、モジュールのグローバルモジュールフラグメントでインクルードしたヘッダの内容はインポートした側からは参照することができません。

import mylib; // mylibモジュールのインポート宣言

int main() {
  std::cout << "hello world.";  // コンパイルエラー!
}

グローバルモジュールフラグメントでヘッダをインクルードするように推奨するのは、このような保護が働くためです。というか、グローバルモジュールフラグメントはそのために用意されたものです。

おまけ 一括export

公開する宣言にexportを付けるというのは、その数が多くなると面倒くさくなりがちです。そこで、ある名前空間にあるものをすべてエクスポートする場合は、その名前空間ごとエクスポートすることで一括処理可能です。

/// mylib.ixx

export module mylib;  // mulibモジュールのインターフェース単位の宣言

// 名前空間ごとエクスポート!
export namespace mylib {

  // クラスのエクスポート、暗黙に全メンバがエクスポートされる
  class S {
    int m_num = 0;
  public:

    S();
    S(int n);

    int get() const;
  };

  // フリー関数のエクスポート
  void print_S(const S& s);
}

また、エクスポートしたいものをある程度まとめることができる場合、ブロックで囲うことでも一括処理することができます。

/// mylib.ixx

export module mylib;  // mulibモジュールのインターフェース単位の宣言

namespace mylib {

  // エクスポートブロック、このブロックはスコープを作らない
  // 内部のものはすべてエクスポート!
  export {
     // クラスのエクスポート、暗黙に全メンバがエクスポートされる
     class S {
       int m_num = 0;
     public:

       S();
       S(int n);

       int get() const;
     };

     // フリー関数のエクスポート
     void print_S(const S& s);
  }

  // エクスポートされない
  void private_func(int);
}

このような一括exportをする場合どちらの方法を使った時でも、exportしているブロック{}の内部でexportすることはできません。

Discussion