[C++] モジュールへの移行を考える 2 - 実装の隠蔽について

4 min read読了の目安(約4200字

目次(予定)

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

実装の隠蔽について

前回書き忘れていました・・・

前回、次の様な2つのファイルによってモジュールを構成していました。

/// 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);
}
/// 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;
  }
}

そして、このモジュールを利用する側では次のようにします。

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

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

この例ではインターフェース(mylib.ixx)で宣言されたものしか使用していません。もし実装単位でしか宣言・定義されていないものを参照したらどうなるのでしょう?

/// mylib.cpp

module;

#include <iostream>

module 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;
  }

  // 実装単位だけで宣言されている関数
  void private_func() {
    std::cout << "private\n";
  }
}
import mylib;

int main() {
  mylib::private_func();  // コンパイルエラー!
}

モジュールの実装単位(あるいはプライベートモジュールフラグメントの実装部分)だけで宣言されているものは、外部から完全に参照できません。モジュール実装単位にあるものをモジュール外部から参照するには、予めそのインターフェース単位においてexport宣言をしておく必要があります。

「外部から 完全 に参照できない」というのは、従来のソースファイル(.cpp)なら宣言を用意してしまえばヘッダに無い物も呼び出せていたところでも、モジュール実装単位にしかないものはその外側から参照する手段が全く無いという事です。

/// test.h

int f();
/// test.cpp

#include "test.h"

int f() {
  return 10;
}

int g() {
  return 20;
}
/// main.cpp

#include <iostream>

#include "test.h"

int g();  // 対応する宣言を勝手に追加してしまう

int main() {
  std::cout << f() << std::endl;  // 10
  std::cout << g() << std::endl;  // 20
}

こんな事をすべきではありませんが出来てしまいます。これもヘッダファイルがプログラム分割のための適切な手法ではない事の証左でもあります。

モジュールの実装単位では、exportされていない宣言・定義を呼び出す手段はありません。そして、exportはモジュールのインターフェース単位でしか行うことができません。従って、モジュールの実装部にあるものは外部からは完全に隠蔽されています。

このことは実装単位でもインターフェース単位でも変わりません。インターフェース単位でexportしていない宣言をモジュール外部から呼び出す手段は基本的にはありません。

/// mylib.ixx

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);

  
  // エクスポートされない関数
  void non_expote_func(int n) {
    std::cout << n;
  }
}
import mylib;

int main() {
  mylib::non_expote_func();  // コンパイルエラー!
}

(この含みのある言い方は、実は間接的に呼び出す手段があることを意味しています・・・・)

リンケージ

リンケージという言葉が分かる人に向けて少し詳しく解説です。

先程の宣言を勝手に追加して呼び出せてしまう例がなぜできるのかというと、関数g()が外部リンケージを持っているからです。外部リンケージを持つ名前は翻訳単位を超えて参照することができます。従って、ヘッダファイルに無く見えていなくても、全く同じ名前を用意することができれば、それを通じて翻訳単位を超えた参照を行えるわけです。
これを呼び出せなくするには内部リンケージを与えてやれば良くて、そのもっとも単純な方法はstaticを付加することです。

C++20においてもリンケージの持つ意味は変わっていません。そしてモジュールでは、export宣言による名前だけが外部リンケージを持ちます。従来内部リンケージを持つものはそのまま内部リンケージをもつためその翻訳単位内でしか参照できません。
残った、従来は外部リンケージを持っていたものについては、新しいリンケージ区分であるモジュールリンケージが与えられます。

モジュールリンケージを持つ名前は同じモジュール(モジュール宣言時の名前が同じモジュール名のモジュール)内でのみ翻訳単位を超えて参照することができますが、モジュールの外側からは参照できません。これはちょうど内部リンケージを同モジュール内部まで少しだけ拡張したものです。
そして、一度与えられたリンケージはその後変化することはありません。

/// mylib.ixx

export module mylib;

namespace mylib {

  export int f(); // 外部リンケージ
  static int g(); // 内部リンケージ
  int h();        // モジュールリンケージ
}
/// mylib.cpp

module mylib;

namespace mylib {

  // 再宣言、外部リンケージを持つ
  int f() {
    return 1;
  }

  // インターフェース単位のg()とは別物、内部リンケージ
  static int g() {
    return 2;
  }

  // 再宣言、モジュールリンケージを持つ
  int h() {
    return 3;
  }
}