組み込みと Pimpl イディオム

2020年1月17日

Pimpl イディオムと呼ばれるテクニックがあります。イディオムとは「慣用句」とか「言い回し」といった意味の単語で、要するにパターンのことです。

正直に言うと、私はこのテクニックが嫌いです。どうやってもソースコードを汚すことになるし、その犠牲によって得られる恩恵が大したものに思えない。特に組み込みでは。

しかし最近、これをうまく使っている人を見て、少しだけ考えを改めました。もしあなたがライブラリを提供する立場で、そのファイル構成をシンプルにして提供したいと思うのであれば、Pimpl イディオムを局所的に使うことには一考の余地があります。

メリットとされていること

Pimpl とは “Pointer to Implementation"、つまり「実装へのポインタ」から来ているプログラミング用語です。一度 “Pimpl" をググってみてください。大抵は、このようなメリットが強調されています。

  • private なものを隠すことができる
  • コンパイル時間が短くなる

私にはこれらが、著しくソースコードを汚してまでやる価値のあることとは思えません。

ですが、もしプロダクト本体とはある程度切り離されたパッケージ、たとえばライブラリなどを C++ で提供しようとすると、C++ が持つある弱点に気づかされます。

C++ の弱点

あるクラスライブラリを提供することを考えます。それはある ASIC のデバイスドライバで、このクラスのインスタンスを1つ生成しておけば、あとは簡単に ASIC が取り扱えるといったことを狙いとします。

ここでは提供するクラス名を Asic とし、公開するヘッダファイル名を Asic.hpp とします。Asic.hpp は、次のようなものが理想でしょう。

class Asic
{
public:
  Asic(int ch, int num);
  void initialize();
  int getVersion() const;
  /* 以下、提供したい関数が続く */
};

このような形なら、これを受け取ったユーザーは、このクラスを使って何が実現できるのか、ある程度の察しをつけることができます。つまり使いやすいということです。

しかし現実には、クラスライブラリがやることは複雑で、その実現には多くの private メンバを必要とするでしょう。このように。

#include "Status.hpp"
#include "FixedPoint.hpp"

class Asic
{
private:
  bool m_initialized;
  Status m_status;
  FixedPoint m_value;
  /* 以下、必要な変数・関数が続く */

public:
  Asic(int ch, int num);
  void initialize();
  int getVersion() const;
  /* 以下、提供したい関数が続く */
};

ここで問題なのは、private メンバが晒されるという点ではありません。これを公開するために、他の多くのヘッダファイルを道連れにしなければならないことのほうが問題です。この例では、ライブラリの提供を受けるユーザーは少なくとも次の3つのヘッダファイルを示されることになります。

  • Asic.hpp
  • FixedPoint.hpp
  • Status.hpp

この例は簡潔に書いていますが、実用上はこんなものではありません。しかも、インクルードはインクルードの連鎖を生み、道連れにされるファイルが芋づる式に増えていくことは必至です。

ユーザーは莫大な量のヘッダファイルを提示されて、その中からどれが本体であると知ればいいのか? これが C++ の弱点です。

Pimpl の使い方

私が考える Pimpl の用途はただ一つ。インクルードの連鎖を断ち切って、ライブラリのユーザーに向けて、提示するファイルを絞り込むことです。

イディオムと呼ばれるように、Pimpl への置き換えはある程度機械的に行うことができます。ここからはその手順を示します。

1. クラスを複製する

Asic.hpp にある Asic クラスをコピペして、Asic.cpp に新しいクラスを置きます。これをヘッダ側に置いてはいけない点に注意してください。クラス名は Asic::Impl とするのがおすすめです。宣言・定義ともに完全にコピーしてください。当然ながらコンストラクタ名だけは変更が必要です。

#include "Status.hpp"
#include "FixedPoint.hpp"

class Asic::Impl
{
private:
  bool m_initialized;
  Status m_status;
  FixedPoint m_value;
  /* 以下、必要な変数・関数が続く */

public:
  Impl(int ch, int num);
  void initialize();
  int getVersion() const;
  /* 以下、提供したい関数が続く */
};

この時点で、cpp ファイル側に大量のヘッダファイルがインクルードされることになります。

2. もともとの private メンバを削除する

もともとあった Asic クラスの private メンバを変数・関数問わずすべて削除し、Impl へのポインタのみにします。Asic.hpp は、次のようになります。

class Asic
{
public:
  class Impl;

private:
  Impl *const m_pimpl;

public:
  Asic();
  void initialize();
  int getVersion() const;
  /* 以下、提供したい関数が続く */
};

今度は逆に、hpp ファイルから大量のインクルードファイルが取り除かれることになります。ちなみに Impl というクラス名のスコープを上記のように限定すれば、同様のイディオムで Impl という名前が何度でも使えます。

3. たらい回しコードを書く

また Asic.cpp に戻り、今度は Asic クラスの実装を新たに書きます。とはいえ、ここは完全に機械的なコードになります。全関数を、Impl クラスのそれに委ねるだけだからです。冒頭でコードが汚れると言ったのは、この部分を指します。

Asic::Asic(int ch, int num)
: m_pimpl(new Impl(ch, num))
{
}

void Asic::initialize()
{
  m_pimpl->initialize();
}

int Asic::getVersion() const
{
  return m_pimpl->getVersion();
}

これで Pimpl イディオムは完成です。同じ挙動を維持しながら、公開するヘッダファイルを激減できていることがおわかりでしょうか。この例では、ユーザーに公開するヘッダファイルは、Asic.hpp だけになりました。

動的メモリを使わない方法

上のコードに、new というキーワードが登場していることにお気づきになりましたか? Pimpl イディオムが組み込みに合わないと私が考える、決定的なポイントです。

比較的規模の大きいデバイスドライバであれば、おそらくそのインスタンスは一つしか生成されない、いわゆるシングルトンであることが期待できるでしょう。もしこれがシングルトンなら、救いがあります。new の代わりに placement new が使えるからです。

#include <new>

class Asic::Impl
{
  /* 略 */
};

long long g_buf[(sizeof(Asic::Impl) + 7) / 8];

Asic::Asic(int ch, int num)
: m_pimpl(new(g_buf) Impl(ch, num))
{
}

placement new の詳細についてはここでは述べません(機会があれば別記事にします)が、この方法でどうにか動的メモリを使わない Pimpl が実現できます。

もしシングルトンでなければ、この方法は使えません。動的メモリとの両天秤にかけて、Pimpl 自体の是非をよく検討してください。