序
昔からC++をやっててC++11以降に対応している人にはconstexpr
やそれを付けたコンストラクタは見慣れたものかもしれません。しかし、そうでない人には小ネタになるかもしれないと書いてみました。
1. constexprの前にconstの話
1-1. constを付けない場合
例えば以下のような何の変哲もないコードを考えます。
int var = 1;
int main() {
return var;
}
知りたいのはこのコードからどんなアセンブラが吐かれるか?です(すみませんが、環境はUbuntu gcc-11.4 x86_64のみです)。コンソールから以下のコマンドを打ってアセンブラコードを吐かせます。
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm no_const_primitive.cpp
結果は以下のような形になります
...
.data
.align 4
.type var, @object
.size var, 4
var:
.long 1
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# no_const_primitive.cpp:3: return var;
mov eax, DWORD PTR var[rip] # _2, var
# no_const_primitive.cpp:4: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
注目すべきは.data
セクションにvar
変数が置かれたことです。静的変数は初期値付きのdata
セクションに置かれるか、0初期化されているbss
セクションに置かれます。この変数の内容をmain()
関数ではreturn var
で返すのですが、それをしているのがmov eax, DWORD PTR var[rip]
になります。
1-2. constを付けた場合
const int var = 1;
int main() {
return var;
}
はい、var
の型がint
からconst int
になりました。これでvar
は以降代入操作ができなくなります。このコードを同様にビルドすると…
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm const_primitive.cpp
結果は以下のような形になります
...
.text
.section .rodata
.align 4
.type _ZL3var, @object
.size _ZL3var, 4
_ZL3var:
.long 1
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# const_primitive.cpp:3: return var;
mov eax, 1 # _1,
# const_primitive.cpp:4: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
違いはまずvar
が.rodata
セクションに配置されたことです。.rodata
セクションは読み込み専用のデータ領域であることを表します。例えば
const int var = 1;
int main() {
*const_cast<int*>(&var) = 2;
return var;
}
強制的にconstを外してアクセスするコードを入れると、実行時にセグメンテーションフォルトで落ちます。
またmain()
関数はvar
の値ではなく、直値で1を返すようになりました。これはもはやvarが変数ではなく、定数としての扱いであることを意味しています。今はコンパイル時に最適化をかけていないので、varはメモリ領域を確保できていますが、最適化後はきっと消えてしまうでしょう。
2. constexprを付けるとどうなるのか?
constexpr int var = 1;
int main() {
return var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm constexpr_primitive.cpp
...
.text
.section .rodata
.align 4
.type _ZL3var, @object
.size _ZL3var, 4
_ZL3var:
.long 1
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# constexpr_primitive.cpp:3: return var;
mov eax, 1 # _1,
# constexpr_primitive.cpp:4: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
つまりconstと同じです。primitiveな変数では違いがありません。
3. primitiveからobjectへ
3-1. constなし編
struct _s {
int var;
_s(int var):var(var){}
} s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm no_const_object.cpp
...
; 以下_s::_s()相当
...
_ZN2_sC2Ei:
.LFB1:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
mov QWORD PTR -8[rbp], rdi # this, this
mov DWORD PTR -12[rbp], esi # var, var
# no_const_object.cpp:3: _s(int var):var(var){}
mov rax, QWORD PTR -8[rbp] # tmp82, this
mov edx, DWORD PTR -12[rbp] # tmp83, var
mov DWORD PTR [rax], edx # this_2(D)->var, tmp83
# no_const_object.cpp:3: _s(int var):var(var){}
nop
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
; sはbssセクションに配置される
...
.bss
.align 4
.type s, @object
.size s, 4
s:
.zero 4
...
; メイン関数
...
.text
.globl main
.type main, @function
main:
.LFB3:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# no_const_object.cpp:6: return s.var;
mov eax, DWORD PTR s[rip] # _2, s.var
# no_const_object.cpp:7: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
; 静的初期化/破棄準備
...
.size main, .-main
.type _Z41__static_initialization_and_destruction_0ii, @function
_Z41__static_initialization_and_destruction_0ii:
.LFB4:
.cfi_startproc
...
; この中でsのコンストラクタが呼ばれる
...
# no_const_object.cpp:4: } s{1};
mov esi, 1 #,
lea rax, s[rip] # tmp82,
mov rdi, rax #, tmp82
call _ZN2_sC1Ei #
...
ret
.cfi_endproc
...
一般的な静的オブジェクトの初期化とメイン関数の例でした。bssセクションにあるやつをmain()
呼び出し前に静的初期化でコンストラクタ呼び出しを連打するいつも通りのやつです。
3-2. const編
struct _s {
int var;
_s(int var):var(var){}
};
const _s s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm const_object.cpp
...
; 以下_s::_s()相当
...
_ZN2_sC2Ei:
.LFB1:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
mov QWORD PTR -8[rbp], rdi # this, this
mov DWORD PTR -12[rbp], esi # var, var
# const_object.cpp:3: _s(int var):var(var){}
mov rax, QWORD PTR -8[rbp] # tmp82, this
mov edx, DWORD PTR -12[rbp] # tmp83, var
mov DWORD PTR [rax], edx # this_2(D)->var, tmp83
# const_object.cpp:3: _s(int var):var(var){}
nop
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
; s(_ZL1s)は.commなので(リンカが)bssセクションに配置する
...
.size _ZN2_sC2Ei, .-_ZN2_sC2Ei
.weak _ZN2_sC1Ei
.set _ZN2_sC1Ei,_ZN2_sC2Ei
.local _ZL1s
.comm _ZL1s,4,4
...
; メイン関数
...
.text
.globl main
.type main, @function
main:
.LFB3:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# const_object.cpp:7: return s.var;
mov eax, DWORD PTR _ZL1s[rip] # _2, s.var
# const_object.cpp:8: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
; 静的初期化/破棄準備
...
_Z41__static_initialization_and_destruction_0ii:
.LFB4:
.cfi_startproc
...
; この中でsのコンストラクタが呼ばれる
...
# const_object.cpp:5: const _s s{1};
mov esi, 1 #,
lea rax, _ZL1s[rip] # tmp82,
mov rdi, rax #, tmp82
call _ZN2_sC1Ei #
...
ret
.cfi_endproc
...
細かい部分に若干の違いはありますが、動作内容はconstがないケースと変わりません。つまり、このケースでのconstは純粋にコンパイル時点での型チェックでのみ機能しているようです。まあ初期化のためにコンストラクタ動かさないといけないので結局動作に違いはないんですよね。
3-3. constexpr編
struct _s {
int var;
_s(int var):var(var){}
};
constexpr _s s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm constexpr_object.cpp
constexpr_object.cpp:5:14: error: the type ‘const _s’ of ‘constexpr’ variable ‘s’ is not literal
5 | constexpr _s s{1};
| ^
constexpr_object.cpp:1:8: note: ‘_s’ is not literal because:
1 | struct _s {
| ^~
constexpr_object.cpp:1:8: note: ‘_s’ is not an aggregate, does not have a trivial default constructor, and has no ‘constexpr’ constructor that is not a copy or move constructor
$
何やら怒られています。コンストラクタを持つstructで、constexprでないコンストラクタで初期化されてるので、定数=リテラル(constexpr)にできないよと言われています。単に初期化後に変更できないだけのconstとの違いですね。
4. constexprなコンストラクタを付けると…
4-1. constなし編
struct _s {
int var;
constexpr _s(int var):var(var){}
} s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm no_const_object_costexpr_ctr.cpp
...
.data
.align 4
.type s, @object
.size s, 4
s:
# var:
.long 1
...
.text
.globl main
.type main, @function
main:
.LFB3:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# no_const_object_costexpr_ctr.cpp:6: return s.var;
mov eax, DWORD PTR s[rip] # _2, s.var
# no_const_object_costexpr_ctr.cpp:7: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
コンストラクタの実装コードが生成されなくなりました。コンストラクタを実行した結果のメモリ構造が.data
セクションにsとして配置されているのが分かります。それに伴い静的初期化時のコンストラクタ呼び出しが消えています。
またmain()
関数ではsの値をちゃんと返しています。つまりオブジェクトとしては書き換えが可能なのです。
4-2. const編
struct _s {
int var;
constexpr _s(int var):var(var){}
};
const _s s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm const_object_costexpr_ctr.cpp
...
.text
.section .rodata
.align 4
.type _ZL1s, @object
.size _ZL1s, 4
_ZL1s:
# var:
.long 1
...
.text
.globl main
.type main, @function
main:
.LFB3:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# const_object_costexpr_ctr.cpp:7: return s.var;
mov eax, 1 # _1,
# const_object_costexpr_ctr.cpp:8: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
primitiveなときと同様に.rodata
セクションに配置されるようになり、main()
関数ではs.varではなく直値の1が返されています。
4-3. constexpr編
struct _s {
int var;
constexpr _s(int var):var(var){}
};
constexpr _s s{1};
int main() {
return s.var;
}
$ g++ -Wall -pedantic -std=c++11 -S -masm=intel -fverbose-asm constexpr_object_costexpr_ctr.cpp
...
.text
.section .rodata
.align 4
.type _ZL1s, @object
.size _ZL1s, 4
_ZL1s:
# var:
.long 1
...
.text
.globl main
.type main, @function
main:
.LFB3:
.cfi_startproc
endbr64
push rbp #
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
mov rbp, rsp #,
.cfi_def_cfa_register 6
# constexpr_object_costexpr_ctr.cpp:7: return s.var;
mov eax, 1 # _1,
# constexpr_object_costexpr_ctr.cpp:8: }
pop rbp #
.cfi_def_cfa 7, 8
ret
.cfi_endproc
...
はい、constと同じですね。constexprはconst指定を含んでいるので、内容が同じになら同じコードが生成されるのは道理です。
5. まとめ/うんちく
5-1. 今回見たconstexpr
- constexprは変数に指定すると型にconstが付く
- constexprコンストラクタはコンストラクタの実行をコンパイル時にしてしまう
- constexprは最適化とは関係がない
5-2. constはconstexprにした方がいい?
たまにそういう意見を見るのですが、意味が違うので、個人的にはそうしません。むしろ、変数の型にはconstを指定して、関数にはconstexprを指定する感じなので、基本constのままです。
原理主義者の方がどういう考えでそうしてるのかは、良く知りません。
5-3. constexprの泣き所
実行時エラーが出せないところです。constexprの詳細がコンパクトにまとまってる以下のサイト
の備考に若干記載があります。
5-4. なんで今さら記事にしたか
c++20でunionメンバを持つコンストラクタにconstexpr指定できるようになり、std::stringのコンストラクタもconstextr指定できるようになりました。これに伴い、静的に配置されるオブジェクト内のstd::stringが.data
セクションに移動したため、中身空でもそれなりの初期化値領域が必要になり、C++17→C++20で実行ファイルのサイズが増える、という現象を見たからです。
std::array<std::string, 50000> s;
みたいなものでした。C++17までならbssセクションに確保され、コンストラクタで50000個分初期化するのですが、C++20だと50000個分の初期化済データ(約1.5MB)がdataセクションに追加され(gcc 13以上/clang 17以上)、そのまま実行ファイルのサイズが急増した、というものです。unionに使ってるメンバ切り替え用の変数初期値が0だったら…とちょっと思いました。