2014年4月17日木曜日

構造体のサイズに関する面倒な問題

Windows の API では、構造体にその構造体のサイズを表すメンバを含んで、呼び出し側で構造体のサイズを設定することにより、 将来構造体のメンバが増えても互換性が保てるようにしていることが多いということを前回書きました。

しかし、この方法は面倒な問題をはらんでいます。
例えば、以下のような構造体があったとします。

typedef struct PERSON_TAG {
  DWORD cbSize;
  int   nAge;
} PERSON;

これを利用する際に、先頭のメンバに構造体のサイズを代入するようにします。

PERSON person;
person.cbSize = sizeof(PERSON);
person.nAge   = 17;
SetPerson(&person);

新しい SDK でメンバが増えて、以下のようになったとします。

typedef struct PERSON_TAG {
  DWORD cbSize;
  int   nAge;
#if _WIN32_WINNT >= 0x0900
  int   nWeight;
#endif
} PERSON;

#define PERSON_V8_SIZE CCSIZEOF_STRUCT(PERSON, nAge)

先ほどのコードを新しい SDK を利用してそのままコンパイルすると、cbSize は新しいサイズになりますが、 新しいメンバ nWeight に関しては何も設定していないため、問題が起こる可能性があります。 また、過去のバージョンの OS で動作しなくなってしまいます。

それを避けるためには、_WIN32_WINNT を古いバージョンで定義すればいいわけですが、 しかし他の場所で新しい OS の機能も利用したいという場合、困ってしまいます。
そういう場合は、以下のように書き直すことになります。

person.cbSize = PERSON_V8_SIZE;

これでこの部分に関しては問題なくなるのですが、しかしどの構造体にメンバが追加されたかを調べて、 それを利用している部分を探して書き直して…、と一々やるのは面倒です。

Microsoft もこの辺りの問題を認識しているのでしょう、 問題の発生が多そうな構造体に関しては既存の構造体にメンバを追加するのではなく、 新しい構造体を定義してしまうということがされています。
例えば OSVERSIONINFO はメンバが追加されて OSVERSIONINFOEX に、MONITORINFOMONITORINFOEX に、という具合です。
しかし別の構造体にしてしまうと、API 関数に渡す時にキャストする必要が出てくるのが良くないですね。

2016/4/24追記

いつからか、C++ では MONITORINFOEX は MONITORINFO から派生するようになったため、C++ ではキャストの必要がなくなりました。
しかし、派生したことによってアグリゲートでなくなったため、以下のような初期化がエラーになってしまうという別の問題が発生しています。

// error C2440
MONITORINFOEX mi = {sizeof(MONITORINFOEX)};

追記終わり

私は、そもそも構造体のサイズを指定するのに sizeof を使わせるのが良くないと思います。
PERSON の例で言えば、PERSON_V8_SIZE をメンバが追加された段階で定義するのではなく、最初のバージョンで定義して、 sizeof(PERSON) ではなく PERSON_V8_SIZE を使うようにドキュメントに記述するようにします。
そうすれば、SDK を更新しても書き直す必要は無くなります。

構造体の互換性を保つ方法として、サイズを設定する以外に、どのメンバが有効かフラグで指定する、という方法もあります。
例えば以下のように定義して、

typedef struct PERSON_TAG {
  DWORD mask;
  int   nAge;
} PERSON;

#define PERSON_MASK_AGE 0x0001

以下のように利用します。

PERSON person;
person.mask = PERSON_MASK_AGE;
person.nAge = 17;
SetPerson(&person);

こちらの方法では、サイズを指定する場合のような問題は起こりません。
実際に Windows の CommCtrl.h では、こちらの方法が取られているものも多いです。
(しかし、サイズとフラグの両方を設定するようになっているものもあり、今一ちぐはぐなのですが)
この方法の欠点として、メンバが多い場合フラグの指定が長ったらしくなってしまうというのはあります。

2014年4月16日水曜日

CCSIZEOF_STRUCT がバグっている

Windows では、構造体の先頭のメンバで*1、その構造体のサイズを表すようになっているものが多いです。
API を呼び出す側で構造体のサイズを設定することにより、将来 Windows の機能追加で構造体にメンバが追加されても、古いプログラムの互換性が保てるようになっています。
例えば LVGROUP という構造体は、以下のように定義されています。

typedef struct tagLVGROUP
{
    UINT    cbSize;
    UINT    mask;
    LPWSTR  pszHeader;
    int     cchHeader;
    LPWSTR  pszFooter;
    int     cchFooter;
    int     iGroupId;
    UINT    stateMask;
    UINT    state;
    UINT    uAlign;
#if _WIN32_WINNT >= 0x0600
    LPWSTR  pszSubtitle;
    UINT    cchSubtitle;
    LPWSTR  pszTask;
    UINT    cchTask;
    LPWSTR  pszDescriptionTop;
    UINT    cchDescriptionTop;
    LPWSTR  pszDescriptionBottom;
    UINT    cchDescriptionBottom;
    int     iTitleImage;
    int     iExtendedImage;
    int     iFirstItem;
    UINT    cItems;
    LPWSTR  pszSubsetTitle;
    UINT    cchSubsetTitle;
#endif
} LVGROUP, *PLVGROUP;

_WIN32_WINNT >= 0x0600 ということは、pszSubtitle 以降のメンバは Windows Vista で追加されたということになります。
これを以下のように利用すると、

LVGROUP lvg;
lvg.cbSize = sizeof(LVGROUP);

_WIN32_WINNT が 0x0600 以上の場合、Vista より前の XP などでは動作しなくなります。
そういう時に困らないようにということで、過去のバージョンの構造体のサイズを表す定数が定義されています。
LVGROUP の場合、LVGROUP_V5_SIZE として Vista で追加されたメンバを除いたサイズが、以下のように定義されています。

#define LVGROUP_V5_SIZE CCSIZEOF_STRUCT(LVGROUP, uAlign)

CCSIZEOF_STRUCT というのは、構造体のあるメンバまでのサイズを求めるマクロで、意味としては以下のような計算を行うようになっています。

offsetof(struct, member) + sizeof(struct.member)

しかし、実はこれでは問題があるのです。それは、構造体のパディングが考慮されないという点です。
ある構造体 A があったとして、sizeof(A) は A のメンバのサイズの合計になるでしょうか。
答えは「なる場合もあれば、ならない場合もある」です。
なぜなら、構造体のメンバ間や末尾には、CPU の都合のいいようにパディングと呼ばれる余分な領域が追加されることがあるからです。

これにより、x64 ビルドで _WIN32_WINNT の値を 0x0600 未満に定義した場合(つまり Vista 以降のメンバを除外した場合)、sizeof(LVGROUP) は 56 ですが、 _WIN32_WINNT を 0x0600 以上に定義した場合の LVGROUP_V5_SIZE は 52 となり、サイズが食い違ってしまいます。

これは Windows のヘッダのバグでしょう。
私はこのバグにはまってしまい、x86 ビルドだと動くが x64 ビルドだとなぜか動かないという問題が出て、原因の解明に時間を掛けてしまいました。

構造体とサイズを表すメンバに関係して、微妙な問題が他にもあるので、次に続きます。

*1: サイズを表すメンバが、構造体の途中にあるものも一部存在します。