C++形式の動的リンク・ライブラリの書き方(msvc編)

  • 11
    いいね
  • 0
    コメント

この記事はADVENTARのC++ Advent Calender 2016の6日目の記事です。

1.はじめに

この記事では、Microsoft Visual C++を使ってC++形式の動的リンク・ライブラリを書く時に見つけたノウハウを解説します。

動的リンク・ライブラリは静的リンク・ライブラリに比べて書くのが難しいです。
メモリやHDD容量が貴重だった時代にはライブラリを共有することで、それらを節約できるメリットが大きかったので、難しくても頑張る価値がありました。
しかし、近年はメモリもHDDもかなり潤沢に使える時代となり、標準ライブラリやシステム・ライブラリのように多数のexeが使う場合を除き、動的リンク・ライブラリの有用性は低下しています。
むしろ、バージョン管理の手間や配布ミス等を考えると、現代はできるだけ静的リンクした方が好ましいと思います。

ああ、いきなり身も蓋もない結論を書いてしまいました。
しかし、動的リンク・ライブラリにもメリットは残っていますし、リソースを管理するライブラリの場合は動的リンク・ライブラリを使った方が好ましいです。

1-1.自作の動的リンク・ライブラリのメリットは?

まずメリットは、使用しているライブラリの隠蔽ができることと思います。

例えば、私は今C++用のオート・シリアライザ(Theolizer)を開発しています。このライブラリはboostを静的にリンクしています。
boostはたいへん強力なライブラリです。オープン・ソースで配布されていますので、使っている人も多いです。バージョンもたくさんあります。
ですので、Theolizerのユーザさんがboostを既に使っているかも知れません。そして、Theolizerが使っているboostとはバージョンが異なるかも知れません。

Theolizerライブラリを静的リンクするとこの2つのバージョンのboostがぶつかるため、何が起こるか分かりません。
このような場合に、boostを静的リンクした動的リンク・ライブラリとしてTheolizerライブラリを提供すればboostを隠蔽でき、安心して使えます。

因みに、Theolizerはここで説明しているように静的リンク・ライブラリと共有ライブラリ、更にboostを同梱した静的リンク・ライブラリの3種を提供しています。

1-2.しかし、隠蔽は弱点になることがあります

メモリ等のリソースをexeとdll間でやり取りする場合に、そのリソース管理するライブラリを静的リンクして隠蔽すると頭痛いです。例えば、標準ライブラリがその筆頭です。他にも該当するライブラリは多いと思います。

もし、exeとdllのそれぞれが標準ライブラリを静的にリンクしていた場合、dllはdllの標準ライブラリがメモリを管理し、exeはexeの標準ライブラリがメモリを管理します。お互いが直接見えないよう隠蔽してしまったのですから、それぞれ異なる管理テーブルを用いて管理されてしまいます。

そして、dllがnewしてdllの標準ライブラリからメモリを受け取ります。そのメモリをexeへ渡し、exe側でdeleteした場合、exeの標準ライブラリへ返却しますね。この場合、メモリの貸出元と返却先が異なるので何が起こるか分かりません。

しかも、C++の場合、ヘッダ・ファイルでクラスを定義することが多く、そのベッダで定義されたメンバ関数でdeleteするかも知れません。一見dll側でdeleteしているようにも見えますが、ヘッダで定義したメンバ関数の実体はexe側に実装される場合もあり、この時そのメンバ関数が呼び出す標準ライブラリはexe側の標準ライブラリになります。
つまり、dllの標準ライブラリから獲得したメモリをexeの標準ライブラリへ返却することが意外に簡単に発生するのです。

それを避けるためにはプログラマは多くの注意を払わないと行けません。dllから受け取ったオブジェクトは必ずdllへ返却しないといけません。exe側でdeleteしてはいけないのです。(注1)
折角C++はリソース管理の煩わしさから開放されるのに、そのメリットが大きく削がれてしまいます。

(注1)
確認までしてはいないのですが、std::unique_ptr<>を使ってもうまく管理できない筈です。
dll側でstd::make_unique<>したオブジェクトをexe側が受け取ってexe側で開放した場合、デリータは
exeとdllのどちらのものが使われるでしょうか?
STLはクラス・テンプレートですから、全てのメンバ関数をinline展開せざる得ません。(後述)
ですのでexe側で定義されたものが使われる筈です。

1-3.ならばどうするか?

標準ライブラリは、動的リンク・ライブラリ版を用いることでexe側とdll側の両方が同じ個体の標準ライブラリを使い、リソース管理テーブルを共通化するしかないように感じます。

実際、boostは、boost自身を動的リンク・ライブラリ(link=shared)とし、そのboostに標準ライブラリを静的リンク(runtime-link=static)するオプションでビルドしようとするとエラーになります。

C:\boost_1_59_0>b2.exe link=shared runtime-link=static
error: link=shared together with runtime-link=static is not allowed
error: such property combination is either impossible
error: or too dangerious to be of any use

因みに、Theolizerライブラリはboostを静的リンクしますが、boostが獲得するリソースを一切exe側で開放しませんので上記問題は発生しません。(これは、exeに提供するdllのヘッダでboostをインクルードしないことで達成できます。)また、標準ライブラリは動的リンクしています。

1-4.まとめると

動的リンク・ライブラリを使うことで、exeとdllで異なるバージョンの同じライブラリを使用することが可能です。
しかし、標準ライブラリのようにメモリ等のリソース管理するライブラリの隠蔽は断念し、動的リンク・ライブラリとして共有した方が安全と思います。

長くなりましたが、ここではそのような使い方を前提として解説します。

2.C++用動的リンク・ライブラリ開発のポイント

Theolizerライブラリは最初に静的リンクで開発しました。この9月に動的リンクに対応したのですが、その時の経験で得られたノウハウを解説します。
用いたコンパイラはMicrosoft Visual Studio 2015 Communityの.NETでない通常のVC++です。

dllの関数や変数をexe側で使えるようにするためにexportします。
そのために下記のようなマクロが定義されているとして説明します。

dllのビルド時:  #define DLL_EXPORT  __declspec(dllexport)
exeのビルド時:  #define DLL_EXPORT  __declspec(dllimport)

2-1.クラスをexportする時

特にDLL_EXPORTを付け忘れてはいけないのはstatic変数です。付け忘れるとexe側とdll側の両方にstatic変数領域が確保されてしまいます。つまり、exe側とdll側のstatic変数の値が一致しなくなるのです。
特にメンバ関数でローカルに定義したstatic変数は付け忘れやすいと思います。(「2-3-2.」参照)

また、多くの場合クラス単位で機能を提供しますから、そのようなつけ忘れを避けるためにもクラス単位でexportするのが望ましいと思います。C++ クラスでの dllimport と dllexport の使用を参照して下さい。

class DLL_EXPORT Singleton
{
public:
    static Singleton& getInstance()
    {
        static Singleton instance;
        return instance;
    }
};

2-2.export出来ないクラス

しかし、クラス・テンプレートやメンバ関数テンプレートを持つクラスは、テンプレート引数として指定するものが事前に決まっている場合を除き、exportできません。
何故なら、exe側で指定されるテンプレート実引数に対応した実体をdll側が含んでていないと、その実体がexportできないからです。(実体がないとexportしようがないです。)

例えば下記のようにBarクラスのget()メンバ関数の定義をdllの.cppファイルに書いた場合、かつ、dllがBarクラスを使っておらず、明示的実体化もしていなかった場合、get()の実体は全くdllに含まれません。
そして、exe側で例えばBaz<int>::get()を呼び出した場合、リンカにて未定義エラーとなります。・・・①
dllにはBaz<int>::get()の実体が含まれていませんので。

dll.h
template<typename tType>
class Bar
{
    tType mData;
    
public:
    tType get() { return mData; }
};
dll.cpp
template<typename tType>
tType Bar<tType>::get()
{
    return mData;
}

これを防ぐためには、下記のようにヘッダ側でget()を定義する必要があります。

exportできないクラス例
template<typename tType>
class Bar
{
    tType mData;
    
public:
    tType get() { return mData; }
};

そして、BarにDLL_EXPORT指定すると折角ヘッダでget()の中身を定義したにも関わらず、get()関数はdllのビルド時にdll内部に実体化されていることになります。もし、dll側でBarが実体化されてなかった場合、①と同じ未定義エラーが発生します。

なので、結論としてはテンプレートは事実上exportできません。
例外はdll側で明示的実体化し、exe側でその明示的実体化されたもののみを使用する場合です。

2-3.C4251警告が出る

2-3-1.いつ出るのか?

「exportされたクラス」が「privateなメンバ変数を持つexportされていないクラス」を使う時に出るようです。しかし、C4251警告の有用性が不透明です。

C++ クラスでの dllimport と dllexport の使用のdllexportクラスのバラグラフに「クラス型のデータまたはクラスを返す関数をエクスポートする場合、必ずそのクラスもエクスポートしてください。」と書かれてます。・・・②

恐らくC4251警告はこれを守るようにするためにあると思いますが、下記のFoo0やBarのようにプライベートなメンバ変数を持っていない時は警告されません。
特にBarのsStaticInt変数はexeとdllで独立に確保されますので危険なクラスです。警告して欲しいです。
Foo0もFoo1もstatic変数を持たないので直接メモリ管理上の問題を引き起こすことはないです。であれば無駄な警告はしないで欲しいです。

dll.h
#if !defined(DLL_H)
#define DLL_H

#ifdef DLL_BODY
    #define DLL_EXPORT  __declspec(dllexport)
#else
    #define DLL_EXPORT  __declspec(dllimport)
#endif

// privateメンバがないのでC4251警告が出ない
class Foo0
{
public:
    int mData;
};

// privateメンバがあるC4251警告が出るが、特に危険なクラスではない
class Foo1
{
    int mData;
};

// privateメンバがないのでC4251警告が出ないが、危険なクラスである!!
class /*DLL_EXPORT*/ Bar
{
    static int& getStaticInt()
    {
        static int  sStaticInt=1;
        return sStaticInt;
    }
public:
    void set(int x) { getStaticInt()=x; }
    int get() { return getStaticInt(); }
};

// exportされたクラス
class DLL_EXPORT Baz
{
public:
    Foo0    mFoo0;
    Foo1    mFoo1;  // C4251警告
    Bar     mBar;

    void    set(int x) { mBar.set(x); }
    int     get() { return mBar.get(); }
};

#endif  // DLL_H

2-3-2.static変数について実験

さて、本当にstatic変数がexeとdllで個別に獲得されるのか、実際にやってみました。
結果、確かにexportしていないクラスBazのstatic変数sStaticIntはexeとdllの両方に個別に存在します。

下記のmain.cppがexe側、sub.cppがdll側です。

main.cpp
#include <iostream>
#include "dll.h"

int main(int argc, char* argv[])
{
    Baz aBaz;

    std::cout << "aBaz.get()=" << aBaz.get() << "\n";
    std::cout << "Bar::get()=" << Bar::get() << "\n";

    aBaz.set(10);
    Bar::set(20);

    std::cout << "aBaz.get()=" << aBaz.get() << "\n";
    std::cout << "Bar::get()=" << Bar::get() << "\n";

    return 0;
}
sub.cpp
#define DLL_BODY
#include "dll.h"

Barクラスはexportしていないため、set()/get()関数の実体はexeとdllの両方にありますが、exe側(main.cpp)から使っているので、aBar.set()/get()ではexe側が使われています。
Bazクラスはexportしているため、set()/get()関数の実体はdll側にだけあります。
C++の文法的にはこれらのset()/get()関数は、Bar, Bazの両方とも同じ一つのsStaticIntをアクセスする筈です。

しかし、実行結果は以下の通りです。

実行結果
 aBar.get()=1
 aBaz.get()=1
 aBar.get()=10
 aBaz.get()=20

staticに宣言しているsStaticIntがexeとdllの両方に個別に存在するということですね。
なのにBarクラスについてC4251警告やその他の警告は出てません。

因みに、BarをexportするとsStaticIntは統一され、下記のようになりました。

Barをexportした時の実行結果
 aBar.get()=1
 aBaz.get()=1
 aBar.get()=20
 aBaz.get()=20

2-3-3.更にSTLを使っていた場合

exportしたクラスがSTLをメンバに持つ場合、上記のFoo1と同じくC4251が出ます。これを消すためには②の指示に従い、使っているSTLを実体化してexportすることになります。

例えば、上記のBazクラスのpublicメンバとして、std::vector<int> mVector;を追加すると、std::vectorだけでなく、std::vectorが含むクラスもexportするようC4251警告がでます。
そして、C4251がでなくなるまでexportしてみたところ、下記にてC4251警告を消すことができました。

std
namespace std
{
        struct DLL_EXPORT _Container_base12;
template class DLL_EXPORT _Vector_val<std::_Simple_types<int>>;
template class DLL_EXPORT _Compressed_pair<std::_Wrap_alloc<std::allocator<int>>,std::_Vector_val<std::_Simple_types<int>>,true>;
template class DLL_EXPORT _Vector_alloc<std::_Vec_base_types<int,std::allocator<int>>>;
template class DLL_EXPORT _Vector_alloc<std::_Vec_base_types<int,allocator<int>>>;
template class DLL_EXPORT vector<int,allocator<int>>;
}

しかし、これは標準ライブラリの内部構造に依存してしまいますので、望ましくない対策です。

2-3-4.C4251はディセーブルでよいと思う

「1-4.まとめると」で記載したような使い方の場合、リソース管理の問題はでません。
また、肝心のstatic変数アクセスについては警告してくれません。逆に問題のないケースで警告してきます。

そして、バージョン更新のリスクも警告してくれません。exeをビルドした時のdll.hと実際にリンクしているdllをビルドした時のdll.hのバージョンが相違すると不整合が生じます。例えば、Foo0のint mData;short mData;へ変わっただけでも不整合になりますが、警告してくれません。

つまり、C4251は有用な警告とは思えませんし、頼ってしまうと却って厄介なのでC4251警告はディセーブルしても良いように思います。#pragma warning(disable:4251)や__pragma(warning(disable:4251))を使えば簡単にできます。

2-4.thread_local領域がexportできない

thread_localなグローバル変数を定義したいことがあります。(errnoのような変数はスレッド毎にユニークなグローバル変数にする必要があります。そのような時ですね。)

まず、下記のようなgGlobalIntをexportしていたとします。

// dll.h
extern int DLL_EXPORT gGlobalInt;

// sub.cpp
int gGlobalInt=0;

マルチ・スレッド化するため、これをthread_localにするため、下記のように定義したとします。

// dll.h
extern int DLL_EXPORT gGlobalInt;

// sub.cpp
thread_local int gGlobalInt=0;

すると、下記エラーになります。

error C2370: 'gGlobalInt': 再定義 ; 異なるストレージ クラスです。

確かにそうですね。宣言側で定義するべきです。

// dll.h
extern thread_local int DLL_EXPORT gGlobalInt;

// sub.cpp
thread_local int gGlobalInt=0;

すると、残念なことに下記エラーになります。

error C2492: 'gGlobalInt': スレッド ストレージ存続期間を持つデータは dll インターフェイスを持てません

なるほど。
そこで、下記にて対策しました。領域管理はdll側に任せしてしまい、gGlobalIntの参照をexe側で受けとってます。
thread_localと言っても単にスレッド毎に獲得されているだけで、通常のメモリと変わりませんので普通にアドレスをやり取りできます。

// dll.h
//extern thread_local int DLL_EXPORT gGlobalInt;
DLL_EXPORT int& getGlobalInt();

// sub.cpp
thread_local int gGlobalInt=0;
int& getGlobalInt()
{
    return gGlobalInt;
}

3.実験ソースのビルド方法(using CMake)

以上の実験で使ったソースとCMakeLists.txtをGistへ上げてます。

CMake 3.5.0以上をインストールしパスを通しておいて下さい。(ここで手短にCMakeのインストール方法を書いてます。)

上記Gistからをダウンロードして解凍したフォルダにて下記操作を行うことで、ビルドし実行できます。
実験の通りFoo1 mFoo1;でC4251警告がでます。
Windows 10 + Visual Studio 2015 Communityで確認してます。

mkdir build
cd build
cmake ..
cmake --build .
cd Debug
main.exe

上記コマンドは極限までオプション指定を省略してます。CMakeは超便利ですが、それに応じて使い方も難しいので何か分からない時は当記事のコメントで尋ねて下さい。できるだけ回答します。

4.バージョンの相違問題について

dllのみ更新した時、問題を起こさないようにするにはどうすればよいのか?
通常はこちらの問題が気になると思います。私もそうです。

しかし、今回の作業中、この点に関しては特に有用な機能に出会いませんでした。
結局、方針としては下記しかないように思います。

  1. 細心の注意を払って互換性を維持する
    C++はC言語に比べてヘッダで実装するものが多く、互換性の維持はよりたいへんですし、より注意力を必要とします。その分、綿密なテストも必要と思います。
    旧バージョン用自動テストを新バージョンのdll向けに走らせてPASSすることを確認できれば良いですね。

  2. exeとdllのバージョンは常に一致させる
    つまり、dllが更新されたらexeをリビルドした上で再テストです。個人的にはこの辺が落とし所のように感じます。

5.まとめ

dll化する際、見落としがちで問題になりやすいのはメモリ問題でした。

  1. ヒープ等のリソースを管理する同じライブラリの実体がexeとdllに分かれると辛い
    貸出元へきちんと返却する必要があり、リソース管理がたいへん難しくなります。
    exeとdll間でやり取りするリソースを管理するライブラリ(標準ライブラリ含む)は、exeとdllで同じものを動的リンクするのが望ましいです。

  2. static変数やグローバル変数はデータ・セグメントに記録されますが、これらは要注意
    油断するとexeとdllの両方に領域が獲得されてしまいます。注意深くexportすることで避ける必要があります。
    できるだけクラス単位でexportすることが望ましいと思います。

  3. C4251警告はディセーブルする
    危険な場面で警告がなく、危険のない場面で警告されます。これでは役に立ちません。

  4. thread_local領域はdllから直接exportできません
    グローバル関数等経由で間接的にexportしましょう。

6.さいごに

当初思っていたより、大きな記事になってしまいました。
C4251が特に頭痛かったです。コンパイラの警告を役に立たないと切って捨てるのは勇気がいりますね。

さて、今回、これを纏めるにあたって追加調査し裏も取りましたが、まだ何か勘違いや見落としがあるかも知れません。
気がついた方は是非コメント下さい。