gccでのstatic constなクラス変数の謎挙動

Posted on

 今日はひたすらアセンブラを追いかけるだけのお話です。ごめんね。

 C++では定数の定義を、defineでなく、static constな変数で行うことが推奨されてるらしいです

// これはC++では(・A・)イクナイ!!
#define VAL (1)
//こっち推奨
const int VAL = 1;

 基本的には、これで問題ないのですが、リンク先によると、この定数がクラス変数の場合、定義と実体の二つに分けないといけないそうです。

//ヘッダファイル側(宣言)
class Test
{
private:
	
protected:
	
public:
	static const int VAL=01234;
};
//ソースファイル側(実体)
//gccでは宣言(ヘッダ)に値を書いても良いけど、VC++等の古いコンパイラだと実体に書かないといけない。
//そのためにenumハックが存在する(上記のリンク先参照)
const int Test:VAL;

 ほえー、なるほど。

 が、しかし。実際には、gccのときは、実体を書かなくても割と大丈夫みたいです。Ubuntu11.04のx86-64のgccで実験してみました(Win32のMingwでも、機械語は違いますが同じ結果でした)。

 以下のソースはgithubからもDLできるよ☆彡 あ、ZIPももちろんあるよ!!

共通のヘッダファイルはこちら。

(Test.h)

#ifndef TEST_H
#define TEST_H

class Test
{
private:
	
protected:
	
public:
	static const int VAL1=1234;
	static const int VAL2=4321;
};

#endif

 特に意味はないです。このTestというクラスの定数を使って、いろいろテストしてみましょう。

 以下test*-*とありますが、これをmakeのビルドターゲットにすると自動でコンパイルとかしてくれます。

make test*-*

(test1-1)実体を定義しないまま、std::coutに入れてみる。

(Test1-1.cc)

#include "./Test.h"
#include <iostream>

int main(int argc, char** argv)
{
	std::cout << "VAL: " << std::hex << Test::VAL1 << std::endl;
	
	return 0;
}

 ヘッダ上のTest::VAL1やTest::VAL2の実体を一切定義していないことに注意していください。

 これをコンパイルして実行すると…

% g++ -o Test1-1.out Test1-1.cc
% ./Test1-1.out 
VAL: 1234

 どうなってるの!?と思って、アセンブラを出力させてみると…

% g++ -S -masm=intel Test1.cc ←こうすると見慣れたINTEL形式で出力してくれます
% cat Test1.s
main:
.LFB963:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	sub	rsp, 16
	mov	DWORD PTR [rbp-4], edi
	mov	QWORD PTR [rbp-16], rsi
	mov	esi, OFFSET FLAT:.LC0
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
;---------------------------
	mov	esi, 1234
;---------------------------
	mov	rdi, rax
	call	_ZNSolsEi
	mov	esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
	mov	rdi, rax
	call	_ZNSolsEPFRSoS_E
	mov	eax, 0
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

 線で囲ったところを見てもらえばわかるとおり、インライン展開されています。ちなみに、1234という数値はこの場所以外一切定義されません。

(test1-2)じゃ、実体を定義したらどうなるの?

 定義を書き加えて

(Test1-2.cc)

//ヘッダファイルとメイン関数の間に追加。
const int Test::VAL1;

 としてアセンブラを出力してみると…。

;実体も定義される
_ZN4Test4VAL1E:
	.long	1234

;(略)

main:
.LFB963:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	sub	rsp, 16
	mov	DWORD PTR [rbp-4], edi
	mov	QWORD PTR [rbp-16], rsi
	mov	esi, OFFSET FLAT:.LC0
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
;---------------------------
	mov	esi, 1234
;---------------------------
	mov	rdi, rax
	call	_ZNSolsEi
	mov	esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
	mov	rdi, rax
	call	_ZNSolsEPFRSoS_E
	mov	eax, 0
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

 Test::VALの値が埋め込みになるのは同じでしたが、それとは独立した実体も定義されました。

 一切最適化オプションをつけなくても、それなりに最適化してくれるみたいです。

(test1-3)ポインタを通す等して、実体が必要な状況をつくってみる

 ポインタを通す場合、アセンブラに直接引数を書くことはできません。メモリ上に一旦置かなければなりません。

(Test1-3.cc)

#include "./Test.h"
#include <iostream>

void outVal(const int* val)
{
	std::cout << "VAL: " << *val << std::endl;
}

int main(int argc, char** argv)
{
	outVal(&Test::VAL1);
	
	return 0;
}

 今度からは面倒なのでビルドターゲットでコンパイルします。†1

% make test1-3
g++ -S -masm=intel Test1-3.cc -o Test1-3.s
g++ -o Test1-3.out Test1-3.cc
/tmp/ccIrdzjI.o: In function `main':
Test1-3.cc:(.text+0x50): undefined reference to `Test::VAL1'
collect2: ld returned 1 exit status
make: *** [test1-3] エラー 1

 案の定実行は失敗ですね。アセンブラコードを見てみると?

;実体は定義されないけど...

;(略)

main:
.LFB964:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	sub	rsp, 16
	mov	DWORD PTR [rbp-4], edi
	mov	QWORD PTR [rbp-16], rsi
;---------------------------
	mov	edi, OFFSET FLAT:_ZN4Test4VAL1E ;使おうとする
;---------------------------
	call	_Z6outValPKi
	mov	eax, 0
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

 予想通りですね。

(test1-4)でも、最適化を掛けると…。

 でもしかし。同じソースのまま、-O3とかを付けて最適化を掛けると、実行可能です。

make test1-4
g++ -S -masm=intel Test1-3.cc -o Test1-4.s -O3 ;ソースは上と同じTest1-3.cc
g++ -o Test1-4.out Test1-3.cc -O3
./Test1-4.out
VAL: 1234
main:
.LFB1004:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	edx, 5
	mov	esi, OFFSET FLAT:.LC0
;std::coutへの出力を行う別の関数(outVal)があったのに、インライン展開されてる。
;(展開されてない、outValの実体も別箇所で存在します。)
	mov	edi, OFFSET FLAT:_ZSt4cout
	push	rbx
	.cfi_def_cfa_offset 24
	sub	rsp, 8
	.cfi_def_cfa_offset 32
	.cfi_offset 3, -24
	.cfi_offset 6, -16
	call	_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l
;----------------------------------
	mov	esi, 1234   ;ハードコーディング。
;----------------------------------
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZNSolsEi
	mov	rbx, rax
	mov	rax, QWORD PTR [rax]
	mov	rax, QWORD PTR [rax-24]
	mov	rbp, QWORD PTR [rbx+240+rax]
	test	rbp, rbp
	je	.L11
	cmp	BYTE PTR [rbp+56], 0
	je	.L9
	movzx	eax, BYTE PTR [rbp+67]

じゃポインタにしないなら良いんだね良かった良かった

 ポインタとして使えないのはdefineでも一緒ですし、だったら同じ条件ですねやったー。

 …と言いたいところだが、(大佐風)微妙にこったことをすると必ず実体の定義が要らなくなるとは限りません。

(test2-1)三項演算子の結果に使う

 三項演算子を使って、フラグによって定数を振り分けたい!ありそうなシチュエーションです。

(Test2.cc)

#include "./Test.h"
#include <iostream>

int main(int argc, char** argv)
{
	int val = argc > 1 ? Test::VAL1 : Test::VAL2;

	std::cout << "VAL: " << val << std::endl;
	
	return 0;
}

 これを最適化なしでコンパイルすると…

% make test2-1
g++ -S -masm=intel Test2.cc -o Test2-1.s
g++ -o Test2-1.out Test2.cc
/tmp/cc87c0Ta.o: In function `main':
Test2.cc:(.text+0x17): undefined reference to `Test::VAL1'
Test2.cc:(.text+0x1f): undefined reference to `Test::VAL2'
collect2: ld returned 1 exit status
make: *** [test2-1] エラー 1

 なんと。駄目でした。アセンブラを読んでみると、

;もちろん実体は定義されてない。

main:
.LFB963:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	sub	rsp, 32
	mov	DWORD PTR [rbp-20], edi
	mov	QWORD PTR [rbp-32], rsi
;---------------------------------- ここから三項演算子
	cmp	DWORD PTR [rbp-20], 1
	jle	.L2
	mov	eax, DWORD PTR _ZN4Test4VAL1E[rip] ;実体は無いのに
	jmp	.L3
.L2:
	mov	eax, DWORD PTR _ZN4Test4VAL2E[rip] ;使おうとする。
;----------------------------------
.L3:
	mov	DWORD PTR [rbp-4], eax
	mov	esi, OFFSET FLAT:.LC0
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
	mov	edx, DWORD PTR [rbp-4]
	mov	esi, edx
	mov	rdi, rax
	call	_ZNSolsEi
	mov	esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
	mov	rdi, rax
	call	_ZNSolsEPFRSoS_E
	mov	eax, 0
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

(test2-2)最適化してみる。

 でも最適化をつけると…

make test2-2
g++ -S -masm=intel Test2.cc -O3 -o Test2-2.s
g++ -o Test2-2.out Test2.cc -O3
./Test2-2.out
VAL: 4321
main:
.LFB1003:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	eax, 4321
	mov	edx, 5
	mov	esi, OFFSET FLAT:.LC0
	push	rbx
	.cfi_def_cfa_offset 24
	mov	ebx, 1234
	.cfi_offset 3, -24
	.cfi_offset 6, -16
	sub	rsp, 8
	.cfi_def_cfa_offset 32
	cmp	edi, 2
	mov	edi, OFFSET FLAT:_ZSt4cout
;----------------------
	cmovl	ebx, eax ;フラグを見て、条件にあうならeaxをebxへ。ここが三項演算子。
;----------------------
	call	_ZSt16__ostream_insertIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_PKS3_l
	mov	esi, ebx
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZNSolsEi
	mov	rbx, rax
	mov	rax, QWORD PTR [rax]
	mov	rax, QWORD PTR [rax-24]
	mov	rbp, QWORD PTR [rbx+240+rax]
	test	rbp, rbp
	je	.L8
	cmp	BYTE PTR [rbp+56], 0
	je	.L4
	movzx	eax, BYTE PTR [rbp+67]

 まさかの分岐命令…なし…!?cmovlはこちら参考

 また、何度もstd::coutの関数を呼ぶのでなく、出力関数は一度だけのようです。

(test2-3)じゃ素直にif文を使う。

 じゃあ、if文で同じことをしましょう。

(Test2-3.cc)

#include "./Test.h"
#include <iostream>

int main(int argc, char** argv)
{
	int val;
	if(argc > 1){
		val = Test::VAL1;
	}else{
		val = Test::VAL2;
	}

	std::cout << "VAL: " << val << std::endl;
	
	return 0;
}
% make test2-3
g++ -S -masm=intel Test2-3.cc -o Test2-3.s
g++ -o Test2-3.out Test2-3.cc
./Test2-3.out
VAL: 4321

 むむ。成功してしまいました。アセンブラは?

main:
.LFB963:
	.cfi_startproc
	push	rbp
	.cfi_def_cfa_offset 16
	mov	rbp, rsp
	.cfi_offset 6, -16
	.cfi_def_cfa_register 6
	sub	rsp, 32
	mov	DWORD PTR [rbp-20], edi
	mov	QWORD PTR [rbp-32], rsi
	cmp	DWORD PTR [rbp-20], 1
	jle	.L2
	mov	DWORD PTR [rbp-4], 1234
	jmp	.L3
.L2:
	mov	DWORD PTR [rbp-4], 4321
.L3:
	mov	esi, OFFSET FLAT:.LC0
	mov	edi, OFFSET FLAT:_ZSt4cout
	call	_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
	mov	edx, DWORD PTR [rbp-4]
	mov	esi, edx
	mov	rdi, rax
	call	_ZNSolsEi
	mov	esi, OFFSET FLAT:_ZSt4endlIcSt11char_traitsIcEERSt13basic_ostreamIT_T0_ES6_
	mov	rdi, rax
	call	_ZNSolsEPFRSoS_E
	mov	eax, 0
	leave
	.cfi_def_cfa 7, 8
	ret
	.cfi_endproc

 分岐命令で割り振るまでは三項演算子と同じ。でもif文の場合はインラインで定数を書いてくれるみたいですね。

 ちなみに、最適化をかけたときの結果は、三項演算子の時と殆ど同じになりました(test2-4)。

残りのテストの概要

 残りのテストの結果は上記の結果から推測できる面白くない結果だったので概要だけ。

  • test3:三項演算子でも実体が書き加えてあればちゃんと動作します。そりゃそうさね。
  • test4-1:テンポラリな変数に三項演算子を代入するのでなく、そのままstd::coutに突っ込むところに直接書きました。でも駄目でした。
  • test4-2:4-1をコンパイルする際に最適化を掛けると、やっぱり大丈夫でした。

 どうやら、三項演算子はif文に展開されたりするわけではなく、別扱いになっていて、最適化フラグが掛からないときは扱いが少し違う…そんな感じでしょうか。gccのソースは読んでないのでわかりませんが…。

 エミュレータで実体を定義し忘れてたのになぜか動いていたのですが、三項演算子を使ったところでコンパイルエラーでした。なお、実体を定義する前にVC++でビルドしてたので、VC++も同じようにやってくれるみたいですが、最適化の結果どうなるのか、とかは確かめてないです。

けつろん!

  • ちゃんと実体書こう!
  • define替わりに使うならenumハックを検討しよう!

 ほんとC++は黒魔術やでぇ…いえ、仕様通り書かない私が悪いんです。でも動いちゃうのもちょっと困るよ…。

  • †1: といっても、そのmakefileも私が書いたんですが^^;

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください