こんにちは。GWも本稼働中の田原です。

CMakeはCMakeLists.txtでプロジェクトを定義します。1つのプロジェクトには複数のバイナリを生成(ビルド)するための定義が含まれることが少なくありません。そして、配布用のバイナリ、自動テスト用のバイナリ、インストーラ等など複数のサブ・プロジェクトに分解したくなることもあります。(個人的には半年以上継続するようなプロジェクトは皆これに該当しました。意外に多いです。)今回はそのような場面向けのCMakeコマンドであるadd_subdirectoryについて解説します。

1.CMakeのプロジェクトはフォルダ1つに1つまで

CMakeLists.txtのファイル名はこれ1つだけです。他の名前へ変更する方法はないようです。同じ名前のファイルはフォルダ毎に1つまでしか置けませんので、CMakeのプロジェクトとしてもフォルダ毎に最大1つしか置けません。

サブ・プロジェクトについても同様です。サブ・プロジェクトに分割する時、他のプロジェクトのファイルと入り交じると管理しづらいため、別フォルダに保存することが多いです。従って、CMakeLists.txtの名前が1つだけというのは大胆に見えますが、なかなか良い割り切りをした仕様と思います。

2.add_subdirectory

先述の通り、CMakeではサブ・プロジェクトをフォルダ(ディレクトリ)に分割します。
なので、「サブ・プロジェクトの追加=ディレクトリの追加」ということになります。
つまり、add_subdirectoryコマンドで指定したディレクトリ(フォルダ)にある CMakeLists.txt が処理されるのです。

なお、CMakeLists.txtが処理されるのは「ビルド・システム(=Makefile)生成モード」だけです(「スクリプト・モード」では使えない)ので、add_subdirectoryは「ビルド・システム(=Makefile)生成コマンド」です。

3.exeとdll生成サンプル

早速サンプルです。単にadd_subdirectoryするだけでは簡単すぎるので、少し欲張ってみました。
サブ・プロジェクトを作る時にありがちなケースとしては、exeプロジェクトとdllプロジェクトを分ける時があると思いますので、この2つに分けてみました。また、当講座はC++講座ですので、ついでにC++のdllを作る時の「肝」についても少し説明してみます。

3-1. rootフォルダ

まずフォルダはrootフォルダの下に、dllフォルダ、exeフォルダを設けます。

root/
  dll/
  exe/

このrootフォルダに次のCMakeLists.txtを置きます。

root/CMakeLists.txt
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
cmake_minimum_required(VERSION 3.5.1)
project(sample)
 
if(MSVC)
    string(REPLACE "/W3" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}")
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} /W4 /EHsc")
else()
    set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall -std=c++11")
endif()
 
include_directories("${CMAKE_SOURCE_DIR}/dll")
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin")
 
add_subdirectory(dll)
add_subdirectory(exe)

今回の解説対象のadd_subdirectoryは15行目と16行目にあります。dllやexeはフォルダへの相対パス名です。exeでdllをリンクするための指定(target_link_libraries)を行いますので、dllを先にadd_subdirectoryしています。

ポイントは11行目~13行目にあります。
dllのヘッダ・ファイルをexeが#includeします。その際 ../dll/ファイル名.h でも良いのですが、なるべく依存を減らしたいのでinclude_directoriesにてインクルード・パスを追加しました。
また、デフォルトではdllとexeはそれぞれ異なるフォルダへ出力されるため、実行するのが面倒です。(dllフォルダにパスを通すなど)そこで、両者を1つのフォルダへ集める指定が12行目と13行目です。CMAKE_LIBRARY_OUTPUT_DIRECTORYは dll の出力先を指定します。CMAKE_RUNTIME_OUTPUT_DIRECTORYは exe の出力先を指定します。CMAKE_BINARY_DIRは root に対応するビルド・フォルダの絶対パスになります。そこからの相対で出力先を指定しています。
このように使うとデバッグがやりやすいので重宝します。

ところで、5行目については、string(REPLACE …)についてを、6行目と8行目については、警告レベルをできるだけ上げたものは以下の通りですを参照して下さい。

3-2. dllフォルダ

次にdllフォルダに置くサンプルです。

root/dll/CMakeLists.txt
01
add_library(cpp_dll SHARED cpp_dll.cpp cpp_dll.h)

むちゃくちゃ単純ですね。単にdll名とソース・ファイルを指定しているだけです。必要に応じて各種オプションを追加しますが、基本はこれだけでOKです。

dllのヘッダです。

cpp_dll.h
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <memory>
#include <string>
 
#ifdef _MSC_VER
    #pragma warning(disable:4251)
#endif
 
#ifdef _WIN32
    #ifdef DLL_BODY
        #define DLL_EXPORT  __declspec(dllexport)
    #else
        #define DLL_EXPORT  __declspec(dllimport)
    #endif
#else
    #define DLL_EXPORT
#endif
 
class DLL_EXPORT Foo
{
    // pimplイデオム
    struct Impl;
    std::unique_ptr<Impl>   mImpl;
//  char const* dummy;
public:
 
    // コンストラクタ
    explicit Foo(std::string const& iName);
 
    // デストラクタ
    ~Foo();
 
    // コピー処理群
    Foo(Foo const& iRhs);
    Foo& operator=(Foo const& iRhs);
 
    // 機能提供
    void printHello();
};
  • 5行目
    msvcはC++形式のdllを作る際、あまり有用とは思えない警告C4251を出力しますので、この#pragmaでディセーブルしています。C4251警告は適切に出力することが難し過ぎて不適切な出力をするため「役に立たない」ようですが、C++形式のdllを作る際には注意点がありますので「4.C++形式dllの注意点とpimplイデオム」にて説明します。

  • 8行目~16行目とDLL_EXPORT
    gccはデフォルトでは外部リンケージを持つシンボルを全てエクスポートしますが、msvcは全く逆でデフォルトではシンボルをエクスポートしません。つまり、デフォルトではexe側から何一つ使えない状態となります。
    そこで、エクスポートしたいシンボルには、

    • dllをビルドする際には__declspec(dllexport)を指定します。
    • そのdllを使う時には__declspec(dllimport)を指定します。

この「お約束」はC言語と基本的には同じです。C++用に少し拡張してありクラスにこれらを指定することもできます。クラスに指定すると自動的にメンバ関数と静的メンバ変数が全てエクスポートされます。
そして、dllをビルドしている時のみDLL_BODYシンボルを定義してから cpp_dll.h を#includeすることでどちらを使うのか切り替えています。DLL_EXPORTをFooクラスに指定してこれをエクスポートしています。

pimplイデオム については4.で簡単に説明致します。

cpp_dll.cpp
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
 
#define DLL_BODY
#include "cpp_dll.h"
 
struct Foo::Impl
{
    std::string mName;
 
    // コンストラクタ
    Impl(std::string const& iName) : mName(iName)
    { }
 
    // コピー処理群
    Impl(Impl const& iRhs) : mName(iRhs.mName)
    {
    }
    Impl& operator=(Impl const& iRhs)
    {
        mName = iRhs.mName;
        return *this;
    }
};
 
// コンストラクタ
Foo::Foo(std::string const& iName) : mImpl(new Impl(iName))
{
}
 
// デストラクタ
Foo::~Foo()
{
}
 
// コピー処理群
Foo::Foo(Foo const& iRhs) : mImpl(new Impl(*(iRhs.mImpl)))
{
}
 
Foo& Foo::operator=(Foo const& iRhs)
{
    mImpl.reset(new Impl(*iRhs.mImpl));
    return *this;
}
 
// 機能提供
void Foo::printHello()
{
    std::cout << "Hello, this is " << mImpl->mName << ".\n";
//  dummy=mImpl->mName.c_str();
}

こちらは dll側ですので、DLL_BODYを定義して cpp_dll.h を#includeしています。

若干コードが多くなってしまいましたが、Fooは至極単純なクラスです。機能は下記の2つだけです。

  • コンストラクト時に名前(mName)を設定し、
  • printHello()が呼ばれた時に、”Hello, this is 名前”と表示します。

コピー処理群(コピー・コンストラクタとコピー代入演算子 x 2セット)のためにコードが少し肥大化しています。
コピー処理は一般にはコンパイラが自動生成してくれるのですが、pimplイデオムのために、Foo:mImplポインタを使うのでコンパイラ任せでははまります。今回はサンプルなのでdeleteしてもよいのですが、pimplイデオムで実装する時はコピーを実装するケースが多いと思いますので実装してみました。

3-3. exeフォルダ

最後にexeフォルダに置くサンプルです。

root/exe/CMakeLists.txt
01
02
add_executable(cpp_exe cpp_exe.cpp)
target_link_libraries(cpp_exe cpp_dll)

target_link_librariesで、リンクする dll を指定しています。

cpp_exe.cpp
01
02
03
04
05
06
07
08
09
10
11
12
13
14
#include <iostream>
#include "cpp_dll.h"
 
Foo gFoo0("Bob");
Foo gFoo1("Alice");
 
int main()
{
    gFoo0.printHello();
    gFoo1.printHello();
 
    std::cout << "gFoo0=" << &gFoo0 << "\n";
    std::cout << "gFoo1=" << &gFoo1 << "\n";
}

こちらは dll を使う側ですので、DLL_BODYを定義しないで cpp_dll.h を#includeしています。

3-4. 実行手順と結果

以下のような手順で実行できます。(ubuntuでもできます。--config Releaseは余分ですけど。)

01
02
03
04
> mkdir build
> cd build
> cmake ..
> cmake --build . --config Release
実行結果(msvc)
01
02
03
04
Hello, this is Bob.
Hello, this is Alice.
gFoo0=00007FF7146350F0 sizeof(Foo)=8
gFoo1=00007FF7146350F8

4.C++形式dllの注意点とpimplイデオム

多くのケースで、exeはdllが提供するヘッダ・ファイルを#includeすると思います。そして逆はしない場合がほとんどです。
これによりexeはdllへ依存しますが逆依存を断ち切りモジュール間強度が下がるので多くのケースで好ましい設計です。

さて、dllが更新される度に、exeもリビルドする場合は問題ないのですが、例えばWindows updateのように Windowsのdllが更新された時、自分が作ったアプリもリビルドしないと行けないと流石に辛いですよね。

しかし、ある時、dllのヘッダを変更してdllをビルドしdllのみを配布・更新したと仮定します。この時、exe側は変更前の古いdllのヘッダでビルドされています。exeをリビルドしないってなんとなく怖くないですか?

実際それは恐怖です。
例えば、Fooに int型メンバ変数を追加したと仮定します。新しいdllのFooは(恐らく)4バイト大きな領域を使いますね。
しかし、呼び出し側の exe をリビルドしていない場合、dll側で生成(コンストラクタはdll側にありますから常にそうなります)した Foo インスタンスを exe に渡すと4バイト少ない領域しか確保していません。
例えば、exe側でグローバル変数を Foo aFoo("Bob."); として確保するとexe側は4バイト少ない領域しか確保していないのでグローバル変数領域が壊れる筈です。

ちょっと無理やりですが実験してみました。

  1. まず、bin\Releaseフォルダ(msvc)binフォルダ(gcc)の下にある cpp_exe.exe を同じフォルダにコピーして下さい。
    これは exe 側を更新していないことをシミュレートします。
  2. 次に、cpp_dll.hとcpp_dll.cppのハイライトしている行のコメントアウトを外して、ビルドして下さい。
    Windowsでは下記のような結果になりました。(アドレスは実行の度に変わります。)

    01
    02
    03
    04
    Hello, this is Bob.
    Hello, this is Alice.
    gFoo0=00007FF76B6150F0 sizeof(Foo)=16
    gFoo1=00007FF76B615100
  3. そして先程コピーしておいた “cpp_exe – コピー.exe” を実行してみて下さい
    Windowsでは下記のような結果になりました。

    01
    02
    03
    04
    Hello, this is Bob.
    Hello, this is Bob.
    gFoo0=00007FF7D38250F0 sizeof(Foo)=8
    gFoo1=00007FF7D38250F8

    Aliceとなるべきなのに、メモリが破壊されたためトチ狂って Bob と名乗ってます。

このシナリオは、pimplイデオムを使うことで回避しやすくなります。FooにはImpl(インプリメント)へのポインタを保持しているだけで、他のメンバ変数は一切持ちません。これを義務付けた時点でメンバ変数の追加や削除を禁止したことになりますから、上記のシナリオは発生しないですね。
そして、必要なメンバ変数を Foo::Imple クラスで定義するわけです。これは exe 側から全く見えませんから、サイズの不整合が発生する心配はありません。

4-1. でも本当にそれだけ?

でも、ごめんなさい。pimplイデオムでかなり不整合を回避できるとは思いますが、テンプレートは実装をヘッダに書くことがほとんどですから、完全に回避することは難しいと思います。

とはいえ、exeが#includeするdllのヘッダとdllが#includeするdllのヘッダの相違が不整合を生むので、ヘッダを一切触らなければ大丈夫な筈ですね。
しか~~~し、STLのヘッダもexeとdllの両方が#includeします。他のライブラリを使っていた場合も同様の場合があるでしょう。コンパイラ・ベンダーやそれらのライブラリ・ベンダーが互換性を失うようなバージョンアップをしなければ良いのですが。

正直、怖いので、exeとdllはできる限り一緒にビルドしたものを配布することがお薦めです。(ああ、pimplの立場がない)

5.まとめ

CMakeの講座なのですが、今回はつい C++形式のdllの方に流れてしまいました。需要はあると思うのですが、意外に難しいのでハマりやすいです。この当たりについて以前Qiitaにも投稿しています。かなり細かく議論していますが、もしよろしければ参考にされて下さい。
C++形式の動的リンク・ライブラリの書き方(msvc編)
C++形式の共有ライブラリの書き方(gcc編)

さて、結局、C++形式の時は exe と dll を一緒にビルドし配布することがお薦めです。もし、dllのみの更新が必要な場合にはpimplイデオムの使用を検討されて下さい。

ちなみに、AliceがBobと名乗るサンプルは、ubuntuではAliceはちゃんとAliceと名乗りました。メモリ上でAliceが先に配置されたからです。gFoo0とgFoo1の定義順序を逆にすると誤動作を目で見れるようになるかも知れません。興味のある方はお試し下さい。

それでは今回はこのへんで。お疲れ様でした!