ビルドエラーとの向き合い方
はじめに
本記事では、プログラミングの初学者向けに、ビルドエラーとの向き合い方について筆者がよいと考えていることを述べます。
何かの助けになれば幸いです。
プログラミング言語は C++ を想定しています。
他の言語でも通用する考え方はあるかもしれません。
基本はエラーメッセージ
ビルドエラーを解消するには、何はともあれエラーメッセージを読むことが重要です。
メッセージを読まずにエラーを解消することはできません。
慣れないうちはエラーメッセージを読むことに抵抗があったり、ときには「え、これって読むものなんですか?」という人もいたりしますが...
とにかくエラーメッセージです。
デバッグとの違い
デバッグの場合は、現象を把握するためにデバッガを使ったりログを追加したり、仮説を立ててそれを検証したり... というある種の試行錯誤や経験に基づく勘のようなものが求められます。
一方、ビルドエラーの場合はそういった試行錯誤はほとんど必要なく、基本的にはエラーメッセージさえあれば解消できます[1]。
エラーは上から順に片付ける
C++ では割りとよくあることですが、ほんの少しの変更で大量のエラーが発生することがあります[2]。
予期せず数十や数百ものエラーが発生するとうろたえてしまうのは無理からぬことですが、そういった場合でも落ち着いて先頭のエラーから順にひとつずつ解消していくことが肝要です。
コンパイラはコンパイル中にコードにエラーが見つかっても、できる限りプログラマーの意図を理解しようとがんばってくれます。
しかし、 1 件でもエラーが含まれている時点でそのコードは正しくないわけですから、その後のコンパイラの推測が当たる保証は残念ながらないわけです。
最初の 1 件のエラーを解消することで続く何十ものエラーが発生しなくなるということはめずらしくありません。
エラーの数とその原因の数は必ずしも一致しない
大量のエラーを前にして無条件に怖気づく必要がない理由をもうひとつ述べます。
コンパイラやリンカはエラーの数だけエラーメッセージを出力しますが、プログラマー視点の原因は必ずしもそれと 1 : 1 ではありません。
たとえば、必要なヘッダのインクルードを忘れている場合、そのヘッダ内で宣言されている識別子が not declared であるというコンパイルエラーが大量に出ることがあります。
この場合は、インクルードディレクティブを 1 行追加するだけでそれらのエラーは一気に解消されます。
GCC 11.3.0 (Cygwin64) での例です:
int main() {
foo();
Bar bar;
bar.baz();
}
$ g++ -std=c++20 -Wall -Wextra -Werror -pedantic-errors notdeclared.cpp
notdeclared.cpp: In function ‘int main()’:
notdeclared.cpp:2:9: error: ‘foo’ was not declared in this scope
2 | foo();
| ^~~
notdeclared.cpp:4:9: error: ‘Bar’ was not declared in this scope
4 | Bar bar;
| ^~~
notdeclared.cpp:5:9: error: ‘bar’ was not declared in this scope
5 | bar.baz();
| ^~~
ソースコードの 2 行目の関数 foo
, 4 行目のクラス Bar
, 5 行目の変数 bar
といった識別子がこのスコープで宣言されていないというエラーです。
たったこれだけのコードでコンパイルエラーが 3 件も出ると萎えてしまうかもしれませんが、インクルードディレクティブ (たとえば #include "foobar.hpp"
) を 1 行書けばすべて消えます。
リンクエラーも同様です。
よくある undefined reference という種類のエラーは、必要なライブラリやオブジェクトファイルをリンクするというひとつの修正によって一気に解消する場合が多いです。
同様に GCC 11.3.0 (Cygwin64) での例です:
#include "foobar.hpp"
int main() {
foo();
Bar bar;
bar.baz();
}
$ g++ -std=c++20 -Wall -Wextra -Werror -pedantic-errors undefinedreference.cpp
/usr/lib/gcc/x86_64-pc-cygwin/11/../../../../x86_64-pc-cygwin/bin/ld: /tmp/cc0xNZEm.o:undefinedreference.cpp:(.text+0xe): undefined reference to `foo()'
/usr/lib/gcc/x86_64-pc-cygwin/11/../../../../x86_64-pc-cygwin/bin/ld: /tmp/cc0xNZEm.o:undefinedreference.cpp:(.text+0x1a): undefined reference to `Bar::baz()'
collect2: error: ld returned 1 exit status
今度はコンパイルは正常に終了していますが、リンカが 2 件のエラーを報告しています。
foo()
および Bar::baz()
への未定義の参照があるという内容です。
必要なライブラリやオブジェクトファイルが指定されていないのでリンクすべき関数が見つからないということです。
それら (ライブラリやオブジェクトファイル) をリンカに指定してやれば消えます。
エラーの数が必ずしもそれらの解消に必要な労力や時間に比例しないということを理解しておくことは重要だと思います。
他者に見てもらうときには丸ごと渡す
自分ひとりではエラーを解消できない場合に、同僚に助けてもらうことはよくあると思います。
そのような場合は、エラーメッセージをそのまま渡すことを意識してください。
よかれと思って英語のメッセージを翻訳したり、長いメッセージの一部を切り出したりすることは、かえってエラーの解消を遠ざけてしまうことがあります。
エラーが発生したソースファイルやそれらの構成の情報が必要となることも多いです。
同じオフィスなら自分のデスクに来てもらって、リモートならエラーが発生しているソース群を作業ブランチにコミットするなどして、依頼先の開発者が情報を得られるようにしてあげることも有用です。
もっとも、非常に典型的な場合にはエラーメッセージだけで原因が判明することもあるので、ソースは依頼先から求められてからの提供でもよいと思います。
また、ビルド時にコンパイラやリンカに指定しているオプションといった情報も必要になることがあります。
最後に、他者に情報を伝える際には、「事実」と「意見」を明確に区別してください。
もっとも重要なのは「観測された事実は何か」で、エラーメッセージはこれの代表例です。
エラーの原因を途中まで推測できている場合にそれを伝えることは無意味ではありませんが、それが「推測」であることを明示するのは重要です。
ビルドとは
順番が前後しましたが、ビルドとは何か改めて振り返っておきましょう。
ビルドを構成する工程
普段、何気なく「ビルド」や「ビルドエラー」といった用語を使いがちですが、実際にはビルドは複数の工程から構成されます。
これを理解しておくとビルドエラーの解消に役立つことがあります。
C++ の場合、一般的には最少でも次の 2 工程があります:
- コンパイル
- リンク
補足
コンパイルは C++ ソースファイルを翻訳してオブジェクトファイルを作り出す工程で、リンクはオブジェクトファイルやライブラリから実行ファイルなどを作り出す工程です (figure 1)。
figure 1. ビルド
これらの工程の総称が「ビルド」です。
一連の流れはビルドパイプラインと呼ばれることがあります。
また、ビルドの各工程を担うプログラムの総称をツールチェーンと呼びます。
ツールチェーンには、コンパイラやリンカおよびビルドツールなどが含まれます。
ビルドの流れと順序
figure 1 を見ながらビルドの流れをもう少し考えてみましょう。
C++ では、コンパイルはソースファイルごとに行われます。
実用的なプログラムは複数のソースファイルから構成されるのが一般的ですから、プログラムをビルドする際にはコンパイルは何度も行われます。
一方、リンクはひとつのターゲットにつき一度だけ行われます。
これらを踏まえると、 figure 1 のビルドは次の順序で実行されることがわかります:
- hoge.cpp のコンパイル
- piyo.cpp のコンパイル
- リンク
ただし、 1 と 2 の順序は不定です。
ところで、並列ビルドという、ビルド時間の短縮を狙って複数の工程を同時に実行する手法もあります。
といっても並列化には制約があります。
まず、 1 と 2 は互いに独立しているので並列化できます。
一方、 3 は 1 および 2 の成果物 (アウトプット) をインプットとするので、 1, 2 の両方が正常終了していないと 3 は開始できません。
これらをまとめると、ビルドの実行順序は figure 2 のようになります。
figure 2. ビルドの実行順序
ビルドがこの順序で実行されるということは、ビルドログも同じ順序で出力されます。
実際には、ビルド順序はたいていの場合はビルドツールが決定します。
ビルドツールはビルドの各工程における依存関係を知っており、それを解決する順序を決定してビルドを実行します。
大きなプロジェクトでは、ビルドの正確な実行順序を開発者が特定するのは難しいかもしれません。
そうは言っても、コンパイルが完了する前にリンクが始まることはあり得ず、おおよその実行順序は十分に推測可能です。
このように、ビルドの中で何が行われているかを理解しておくと、エラー発生時にビルドログを詳しく読む前におおよそどこで止まっているのか、その原因として考えられるものにはどのようなものがあるか、推測できるようになります。
コンパイルエラーとリンクエラー
コンパイルエラーはコンパイラがコンパイル中に検出して報告するエラーです。
リンクエラーはリンカがリンク中に検出して報告するエラーです。
両者はまとめてビルドエラーと呼ばれてしまうことも多いですが、実際にはだいぶ異なるものです。
コンパイルエラー
ソースコードが C++ の文法およびその他の規則にしたがっていない (ill-formed である) ことを表します。
本記事でもご紹介した「識別子が宣言されていない」もそうですし、ほかにもさまざまなエラーがあります。
- つづりを誤っている。
- カッコを閉じ忘れている (カッコの対応関係がおかしい)。
- 型が一致しない。
- 引数の数が多すぎる または 少なすぎる。
- ...
あまりにも種類が多いので、「このエラーの場合はこうすればよい」といったノウハウ集は意味がないですね。
その代わりに、コンパイラが詳細なエラーメッセージを出力してくれるので、きちんと読めば解消できるはずです。
コンパイラはエラーを検出した場所をあわせて報告してくれますが、必ずしもエラーの原因の場所ではないことに注意です。
コンパイラが報告した場所よりも前に原因があることもあります。
リンクエラー
指定されたファイル (ライブラリやオブジェクトファイル) が見つからないといった単純なものもありますが、実際に遭遇するリンクエラーの多くはシンボルの解決に関するものだと思います。
コンパイルエラーと違ってリンクエラーは情報量が少なく、特にソースコードの情報はほとんどメッセージに現れないため、慣れるまではどうしたらよいか悩むかもしれません。
これは別にリンカ (の作者) が意地悪というわけではなくて、リンカが受け取るのはコンパイル済みのオブジェクトファイルで、そこにはソースコードの情報はほとんど残されていないためやむを得ないのです。
「シンボルが見つからない (未定義の参照がある)」といったエラーが報告された場合は、次の点を確認されるとよいと思います:
- そのシンボル (たいていの場合は関数やメンバ関数でしょう) を必要としている (呼んでいる) のは意図どおりか?
- そのシンボルを提供しているライブラリやオブジェクトファイル (その関数やメンバ関数を定義しているソースをコンパイルして生成されたオブジェクトファイル) がリンカに渡されているか?
C++ の場合、名前空間名, クラス名, 引数の型や個数, ... といった情報もシンボル名に反映されるため、それらが間違っている場合でも「シンボルが見つからない」といった内容のエラーメッセージとなります。
おわりに
本記事では、プログラミングの初学者がビルドエラーに向き合う際に持っておくとよいであろう考え方・心構えや知識をご説明しました。
具体例に乏しくどのくらいお役に立つかわかりませんが、特にビルドエラーの解消に苦手意識をお持ちの方にご覧いただき、それを少しでも取り除ければ幸いです。
本記事は、職場の後輩との会話や、同じプロジェクトに参画している他社の若手プログラマーとの開発での経験をきっかけに書きました。
-
他者が作ったプログラムを変更する場合など、ソースの構成を把握するための調査や試行錯誤が求められることはありますが... ↩︎
-
C++ では、コンパイルエラーの「爆発」を競うコンテストが開催されているくらいです。 ↩︎
-
Working Draft, Standard for Programming Language C++ [N4861]. http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2020/n4861.pdf. ↩︎
Discussion