こんにちは。TechFUL でアルバイトをしている Arumakan です。
TechFUL ではアルゴリズム構築能力やプログラムの実装能力を問う問題を提供しています。
みなさんはどのようなプログラミング言語で問題を解いているでしょうか。
TCB 参加者、特にランキング上位の方々は C++ を使っている人が多い印象です。
C++ は実行速度が速く、問題を解くのに便利なライブラリが標準で多く提供されています。 私は問題を解くときは C++ を使います。
一方で、C++ は罠が多い言語でもあります。
例えば if (a = b)
のように、比較すべき部分を代入式にしてしまってもコンパイルが通ってしまいます。
また、配列外アクセスやオーバーフローが起きてもエラーにならないことがあり、実装の欠陥に気づけないことがあります。
そこで今回は C++ 使用者を対象に、実装のミスに気づけるようにするための便利な機能を紹介します。 ところどころに難しい言葉があるかもしれませんが、あまり気にせずに、便利なオプションの存在を知っていただけたら幸いです。
- 概要
- 取り扱う C++ のコンパイラ
- 基本的なオプション
- コンパイル時のチェックに関するオプション
- 実行時のチェックに関するオプション
- デバッグをしやすくするためのオプション
- こんなたくさんのオプションを毎回直書きするの?!
- まとめ
概要
C++ のソースコードをコンパイルする際はコンパイルオプションを指定できます。 ある特定のオプションをつけてコンパイルすると、コンパイラがいくつかの実装ミスをコンパイル時に (=実行前に) 教えてくれます。
また、実行時にオーバーフローがおきたときや配列外へアクセスしたときに、そのことを教えてくれるようにすることもできます。
注意:
本記事は手元のテキストエディタ (VSCode 等) でコードを書いて、手元の環境でコンパイル・動作確認をすることが前提です。
TechFUL はWebブラウザ上でコーディング・サンプルのチェックを行うことができますが、問題を解くのに慣れてきた方は手元のPCでもコーディングできるよう環境構築にチャレンジしてみてください。
取り扱う C++ のコンパイラ
現代でよく使われる C++ コンパイラは GCC と Clang かと思います。
TechFUL では C++ のコンパイルに GCC 9.3.0 を使用しています (2021年6月時点)。
ちなみに TechFUL で使用されている言語処理系のバージョンは、下図のように青い i
アイコンにマウスカーソルを重ねると確認できます。
そのため本記事では GCC 9.3.0 をメインにコンパイルオプションを紹介していきます。
以降の内容は GCC 9.3.0 のドキュメント (https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/) にもとづいています。
基本的なオプション
ご存知の方も多いと思いますが、 C++17 規格でコンパイルするには -std=c++17
を指定する必要があります。
GCC 9.3.0 では、C++の規格を指定しない場合は -std=gnu++14
が自動的に指定されます。1
コンパイル時のチェックに関するオプション
怪しい書き方になっている部分を GCC が警告 (Warning) として表示します。
下記に5つほど警告オプションを紹介していますが、警告オプションの形式は全て -W{警告の種類名}
です。
エラーではなく警告なので、コンパイル結果の実行可能バイナリは通常通り生成されます。 つまり警告が出ても実行することは可能です。 また、出力されるバイナリに警告オプションの影響はありません。 警告オプションはつけて損はないと思います。
-Wall と -Wextra : いろいろな警告をまとめて有効化
-Wall
は多くの警告オプションをまとめて有効にします。
-Wextra
は -Wall
で指定されなかったいくつかの警告をまとめて有効にします。
既にご存知かもしれませんが、この2つのオプションを有効にしてコンパイルするには、以下のように空白区切りでオプションを並べてコマンドを実行すればよいです:
g++ -std=c++17 -Wall -Wextra example.cpp
-Wall
と -Wextra
を有効にすることで、例えば下記のような実装の欠陥に気づくことができます:
(1) 変数の初期化のし忘れ
-Wall
で有効になるオプションの一つに -Wuninitialized
があります。
このオプションは初期化していない変数を使用している箇所を警告してくれます。
合計値を求めるときのゼロ初期化忘れや、入力のし忘れはこのオプションで気づくことができます。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wuninitialized
(2) if 文での代入や、混乱しやすい優先順位の演算子
-Wall
は -Wparenthes
も有効にします。
丸括弧に関する警告を有効にしてくれます。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wparenthes
-Wparenthes
で検出可能な、ありがちなミスの例を以下に示します:
// -Wparenthes (-Wall で有効になる) で検出できる実装ミスの例 #include <iostream> using namespace std; int main() { int n; cin >> n; // 検出可能箇所(1): `==` で比較すべきところを `=` で代入してしまったケース。 // `((n = -1))` とすると警告は出ないので注意。 if (n = -1) { cout << "n is minus one." << endl; } // 検出可能箇所(2): `&` と `==` の優先順位を意識し忘れて括弧で囲み忘れたケース。 if (n >> 7 & 1 == 0) { cout << "8th bit of n is zero." << endl; } // 検出可能箇所(3): 数学的に `<` を連続して使用してしまったケース。 // 初学者や Python メインの使用者は はまってしまうかも。 if (2 < n < 5) { cout << "2 < n < 5" << endl; } return 0; }
-Wall
を有効にしてコンパイルすると、-Wparenthes
により下図のように警告で実装ミスに気づけます。
(3) 符号なし整数型との比較
-Wextra
は -Wsign-compare
を有効にします。
このオプションは符号の有無の異なる型での比較を検出します。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wsign-compare
また、-Wextra
は -Wtype-limits
も有効にします。
このオプションは、(型で表現可能な範囲の逸脱が原因の) 常に true となる比較を検出します。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wtype-limits
例えば下記のプログラムのような欠陥に気づくことができます:
// -Wsign-compare と -Wtype-limits (-Wextra で有効になる) で検出可能な実装ミス #include <iostream> #include <string> using namespace std; int main() { string s; cin >> s; /* * 検出可能箇所(1): `i >= 0` は常に true なので無限ループになるケース。 * i は s.length() の型、すなわち size_t 型と推論される。 * size_t 型は符号なし整数型であり、その値は 0 を下回ることはない。 */ for (auto i = s.length() - 1; i >= 0; --i) { cout << s[i] << endl; } /* 検出可能箇所(2): `s.length() - 3` を int 型だと思い込んでしまうケース。 * s.length() は size_t 型であり、3 は暗黙の型変換により size_t 型になる。 * s の文字列長が 3 未満の場合は `s.length() - 3` は負数を表現できず * オーバーフローしてしまう。おそらく i が s の長さを超えて配列外参照が起きる。 * * -Wsign-compare で符号の有無の異なる比較を検出できれば、 * 間接的にこのミスに気づけるだろう。 */ for (int i = 0; i < s.length() - 3; ++i) { cout << s.substr(i, 3) << endl; } return 0; }
-Wall
や -Wextra
は上記で述べた以外にも多くの警告オプションを有効にします。
興味があったらドキュメントや man
コマンドで調べてみてください。
==== 以降は -Wall, -Wextra で有効にならない警告オプションです ====
-Wshadow : シャドウイングを警告
変数のシャドウイングを警告してくれます。 シャドウイングとは内側のスコープの変数が外側のスコープの変数の名前を隠すことです。
波括弧ブロック内の変数が意図せずに外側の変数の名前を隠していることに気づくことができます。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wshadow
-Wconversion : 暗黙の型変換を警告
暗黙の型変換を警告します。 この警告を有効にすると、かなりたくさん警告がヒットして警告ばかりになってしまうかもしれません。
実数型→整数型 や、符号なし整数型↔符号付き整数型、大きい型→小さい型 (例: 64bit型 → 32bit型 など) といった暗黙の型変換を警告します。
int32_t a = value_64bit;
のようにうっかり 64bit → 32bit にしてしまうケースはこのオプションで検出できます。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wconversion
-Wfloat-equal : 実数値の等号比較
浮動小数点型での ==
および !=
による比較を警告します。
浮動小数点数は丸め誤差や情報落ち、桁落ちで誤差をはらんでいる可能性があります。
==
, !=
による完全一致の比較ではなく許容誤差を設けた比較 (例: fabs(a - b) < 1e-10
) をするべきでしょう。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wfloat-equal
-Wno-char-subscripts : char 型の添字を許す
こちらは警告を有効化するのではなく抑制します。
-Wno-{警告の種類名}
とすると警告を抑制できます。
-Wchar-subscripts
は配列の添字に char 型を使用している箇所を警告します。これは -Wall
で有効になります。
しかし、問題を解く際は ASCII 文字をカウントするためにバケット方式でカウントすることがあるので、この警告はじゃまになるかもしれません。
そこで -Wno-char-subscripts
を指定することで、-Wall
で有効になったオプションを打ち消すことができます。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Warning-Options.html#index-Wchar-subscripts
実行時のチェックに関するオプション
-ftrapv : オーバーフローの検出
-ftrapv
は符号付き整数型の加算・減算・乗算時にオーバーフローを検知するトラップを生成します。
オーバーフローが発生するとトラップに引っかかり、即座にプログラムが終了します。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Code-Gen-Options.html#index-ftrapv
-fstack-protector-all : スタック領域の範囲外書き込みの検出
-fstack-protector
をオンにすると、ローカル変数の配列の範囲外書き込み等を検知できる可能性があります。
-fstack-protector-all
は全ての関数に対してその stack-protector を実装します。
-fstack-protector
の説明:
Emit extra code to check for buffer overflows, such as stack smashing attacks. This is done by adding a guard variable to functions with vulnerable objects. This includes functions that call alloca, and functions with buffers larger than 8 bytes. The guards are initialized when a function is entered and then checked when the function exits. If a guard check fails, an error message is printed and the program exits.
出典: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Instrumentation-Options.html#index-fstack-protector
stack-protector のおおまかな原理としては、
- ローカル変数とリターンアドレスの間の領域に「カナリア (canary)」とよばれる値を挿入しておく
- 関数終了時に canary が変更されていたらバッファオーバーフローが起きたと認識
ということなので、エラーを検知できるタイミングは各関数の終了時です。 また、グローバル領域やヒープ領域上の配列 (std::vector 等) の範囲外アクセスは、この stack-protector では検知できません。
参考: https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/c905.html
-fsanitize=address,undefined : メモリの不正操作や未定義動作の検出
-fsanitize=address,undefined
は -fsanitize=address
と -fsanitize=undefined
を同時に指定したオプションです。
address
と undefined
の間は空白なしでコンマを入れるようにしてください。
-fsanitize=address : メモリエラーの検出
-fsanitize=address
は高速メモリエラー検出器である AddressSanitizer を有効にします。
-fsanitize=address
の説明:
Enable AddressSanitizer, a fast memory error detector. Memory access instructions are instrumented to detect out-of-bounds and use-after-free bugs. The option enables -fsanitize-address-use-after-scope. See https://github.com/google/sanitizers/wiki/AddressSanitizer for more details. The run-time behavior can be influenced using the ASAN_OPTIONS environment variable. When set to help=1, the available options are shown at startup of the instrumented program. See https://github.com/google/sanitizers/wiki/AddressSanitizerFlags#run-time-flags for a list of supported options. The option cannot be combined with -fsanitize=thread.
AddressSanitizer は GitHub で下記のように説明されており、スタック領域やグローバル領域、ヒープ領域での配列外アクセス、メモリリーク、ダングリングポインタの使用などを検知できます。
AddressSanitizer (aka ASan) is a memory error detector for C/C++. It finds:
- Use after free (dangling pointer dereference)
- Heap buffer overflow
- Stack buffer overflow
- Global buffer overflow
- Use after return
- Use after scope
- Initialization order bugs
- Memory leaks
出典: https://github.com/google/sanitizers/wiki/AddressSanitizer#introduction
なお、AddressSanitizer の GitHub では
To get nicer stack traces in error messages add
-fno-omit-frame-pointer
.
と説明されており、有益なスタックトレースを得るには-fno-omit-frame-pointer
を同時につけることが望ましいようです。
また、エラーが起きた行番号を得るために -g
(行番号などのデバッグ情報をバイナリに含めるオプション) をつけると良いです。
試しに下記のプログラムを -fsanitize=address -fno-omit-frame-pointer -g
をつけてコンパイル・実行してみます。
#include <iostream> #include <vector> using namespace std; int main() { vector<int> a(3); cout << a[3] << endl; return 0; }
すると下図のようにして、エラーが起きたことおよびエラーの発生箇所を知ることができます。
-fsanitize=undefined : 未定義動作の検出
-fsanitize=undefined
は高速な未定義動作検出器である UndefinedBehaviorSanitizer を有効にします。
このオプションは -fsanitize=signed-integer-overflow
や -fsanitize=float-divide-by-zero
などいろいろな未定義動作のチェックをまとめて有効化します。
-fsanitize=undefined
の説明:
Enable UndefinedBehaviorSanitizer, a fast undefined behavior detector. Various computations are instrumented to detect undefined behavior at runtime.
出典: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Instrumentation-Options.html#index-003dundefined
例えば以下の未定義動作が検出できます:
- 整数型と実数型でのゼロ除算
- シフト演算子の第2オペランドが負数
- シフト演算子の第2オペランドが第1オペランドの型のビット数より大きい
- 符号付き整数型のオーバーフロー
- NULL 参照
- ビルトイン関数への無効な引数 (例:
__builtin_ctz
や__builtin_clz
の引数に 0 を渡した場合)
-fno-sanitize-recover : エラー検出したら即終了にする
上述の -fsanitize=undefined
は未定義動作を検出してもメッセージを表示したまま処理を継続します。
もしサニタイザに引っかかったら即時にプログラムを終了させたい場合は、-fno-sanitize-recover
を指定すると良いです。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Instrumentation-Options.html#index-fno-sanitize-recover
-D _GLIBCXX_DEBUG : 標準ライブラリでのエラーチェック
デバッグモードのコードを使用してコンパイルします。
同時に _GLIBCXX_ASSERTIONS
マクロが自動で定義されるため、そのマクロで有効になる全てのアサーションチェックが有効になります。
なお -D xxx
は #define xxx
をすることと同じです。-D
の後ろに空白をいれなくても良いです。
このオプションを使うことで、vector の範囲外アクセスのチェックのほか、引数がソート済みであるかのチェックなどが行われます。
ソースコード中に #define _GLIBCXX_DEBUG
をつけたまま提出しないように気をつけてください。
かなり実行速度が遅くなるので、正しい解法でも実行時間制限に引っかかる恐れがあります。
-D
オプションを使えばそのようなリスクはありません。
また、手元で実行して実行速度が遅く感じたら _GLIBCXX_DEBUG
が原因の可能性があるので注意してください。
私は前述の -ftrapv
と -fsanitize
で十分だと感じているので _GLIBCXX_DEBUG
は使っていません。
デバッグをしやすくするためのオプション
この項で紹介するオプションはあまり使わないかもしれません。
-g : デバッグ情報の追加
主にデバッガで有益な情報を得るためのオプションですが、前述の -fsanitize=address
でエラー箇所の行番号を表示させるために必要です。
-g2
, -g3
と指定すればさらに多くのデバッグ向け情報をバイナリに含めることができます。
-ggdb
とすれば GDB 向けにデバッグ情報を含められます。
-E : プリプロセッサの結果を表示
このオプションはマクロ関数がどのように展開されたかを調べたいときに便利です。
#include
や #define
はコンパイルの前段階に処理されます。この前段階を処理するのがプリプロセッサです。
なお、このオプションが指定された場合はプリプロセッサの結果が表示されるだけで、構文チェックおよびコンパイルは行われません。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Overall-Options.html#index-E
例えば下記のソースコードに対して -E
付きで g++ を実行してみてます。
#include <iostream> #define SHOW(x) std::clog << #x ": " << (x) << std::endl int main() { int n; std::cin >> n; SHOW(n); return 0; }
すると iostream
ヘッダファイルが挿入された結果が表示されたあと、SHOW()
マクロ関数が展開された結果を確認できます。
ちなみにこのマクロは変数名とその値を標準エラー出力に表示する便利なマクロです。
// ...(略)... int main() { int n; std::cin >> n; std::clog << "n" ": " << (n) << std::endl; return 0; }
-fverbose-asm : アセンブリを読みやすくする
-fverbose-asm
をつけると、生成されるアセンブリコードにもとのソースコードの情報がコメントで挿入されて読みやすくなります。
コンパイル方法やソースコード例、実行結果例はドキュメントを参照してください。
参考: https://gcc.gnu.org/onlinedocs/gcc-9.3.0/gcc/Code-Gen-Options.html#index-fverbose-asm
ところで問題を解くときにアセンブリを読む人はいるのでしょうか...?
-D {提出先ではdefineされていないマクロ名}
最後は若干趣旨が違いますが紹介しておきます。
例えば -D MY_LOCAL_DEBUG
とします。
次のようなソースコードをテンプレに持っておけば、自分の環境でのみ SHOW()
が実行され、提出先では SHOW()
が実行されないようにすることができます。
もし提出先で SHOW()
が実行されると実行速度が遅くなる恐れがあります。
#include <iostream> #ifdef MY_LOCAL_DEBUG #define SHOW(x) std::clog << "[debug] " #x ": " << (x) << std::endl #else #define SHOW(x) #endif using namespace std; int main() { return 0; }
こんなたくさんのオプションを毎回直書きするの?!
多くのオプションを毎回直書きするのは大変です。 次のような方法で解決できると思います:
とにかくコマンドをコピペしたい人向け
とにかくおすすめのオプション全部盛りをコピペしたい方もいらっしゃると思います。
例えば alias
コマンドで新たに g+
コマンドを定義するには、次の1行を ~/.bashrc
や ~/.zshrc
に追加すればよいです:
alias g+='g++ -std=c++17 -g -Wall -Wextra -Wshadow -Wconversion -Wfloat-equal -Wno-char-subscripts -ftrapv -fsanitize=address,undefined -fno-omit-frame-pointer -fno-sanitize-recover'
ちなみに↑このコマンドには -fstack-protector-all
と -D_GLIBCXX_DEBUG
は入っていません。
入れたい方はお好みでどうぞ。
まとめ
小手先のテクニックでしたが、使わないよりは使ったほうが高得点をとりやすいと思います。
この記事をとおしてあなたが得点アップできたのなら幸いです。