C ++のしくみ:コンパイルを理解する
公開: 2022-03-11BjarneStroustrupのC++プログラミング言語には、「C ++のツアー:基本」というタイトルの章があります—標準C++。 その章の2.2では、C++でのコンパイルとリンクのプロセスについて半ページで説明しています。 コンパイルとリンクは、C ++ソフトウェア開発中に常に発生する2つの非常に基本的なプロセスですが、奇妙なことに、多くのC++開発者には十分に理解されていません。
C ++ソースコードがヘッダーファイルとソースファイルに分割されるのはなぜですか? コンパイラーは各部分をどのように認識しますか? それはコンパイルとリンクにどのように影響しますか? あなたが考えたかもしれないが、慣例として受け入れるようになったこれらのような多くの質問があります。
C ++アプリケーションを設計している場合でも、新しい機能を実装している場合でも、バグ(特に特定の奇妙なバグ)に対処しようとしている場合でも、CとC ++コードを連携させようとしている場合でも、コンパイルとリンクがどのように機能するかを知っていると、時間を大幅に節約できます。それらのタスクをはるかに快適にします。 この記事では、まさにそれを学びます。
この記事では、C ++コンパイラがいくつかの基本的な言語構造でどのように機能するかを説明し、それらのプロセスに関連するいくつかの一般的な質問に答え、開発者がC++開発でよく犯すいくつかの関連する間違いを回避するのに役立ちます。
注:この記事には、https://bitbucket.org/danielmunoz/cpp-articleからダウンロードできるソースコードの例がいくつかあります。
例はCentOSLinuxマシンでコンパイルされました。
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
g ++バージョンの使用:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
提供されるソースファイルは他のオペレーティングシステムに移植可能である必要がありますが、自動ビルドプロセス用にそれらに付随するMakefileはUnixライクなシステムにのみ移植可能である必要があります。
ビルドパイプライン:前処理、コンパイル、リンク
各C++ソースファイルは、オブジェクトファイルにコンパイルする必要があります。 複数のソースファイルのコンパイルから得られたオブジェクトファイルは、実行可能ファイル、共有ライブラリ、または静的ライブラリにリンクされます(これらの最後はオブジェクトファイルの単なるアーカイブです)。 C ++ソースファイルには通常、.cpp、.cxx、または.cc拡張子の接尾辞が付いています。
C ++ソースファイルには、ヘッダーファイルと呼ばれる他のファイルを#include
ディレクティブでインクルードできます。 ヘッダーファイルには、.h、.hpp、.hxxなどの拡張子が付いているか、C ++標準ライブラリや他のライブラリのヘッダーファイル(Qtなど)のように拡張子がまったくありません。 C ++プリプロセッサでは、拡張子は重要ではありません。C++プリプロセッサは、 #include
ディレクティブを含む行を、インクルードされたファイルのコンテンツ全体に文字通り置き換えます。
コンパイラがソースファイルに対して実行する最初のステップは、そのファイルでプリプロセッサを実行することです。 ソースファイルのみがコンパイラに渡されます(前処理およびコンパイルのため)。 ヘッダーファイルはコンパイラに渡されません。 代わりに、それらはソースファイルから含まれています。
各ヘッダーファイルは、すべてのソースファイルの前処理フェーズで複数回開くことができます。これは、それらを含むソースファイルの数、またはソースファイルからインクルードされる他のヘッダーファイルの数に応じて異なります(多くのレベルの間接参照が存在する可能性があります) 。 一方、ソースファイルは、コンパイラ(およびプリプロセッサ)に渡されるときに一度だけ開かれます。
プリプロセッサは、C ++ソースファイルごとに、#includeディレクティブが見つかったときにコンテンツを挿入すると同時に、条件付きコンパイルが見つかったときにソースファイルとヘッダーからコードを削除することで変換ユニットを構築します。ディレクティブがfalse
と評価されるブロック。 また、マクロ置換などの他のタスクも実行します。
プリプロセッサがその(場合によっては巨大な)変換ユニットの作成を完了すると、コンパイラはコンパイルフェーズを開始し、オブジェクトファイルを生成します。
その変換単位(前処理されたソースコード)を取得するには、 -E
オプションをg ++コンパイラに渡し、 -o
オプションを指定して前処理されたソースファイルの名前を指定します。
cpp-article/hello-world
ディレクトリには、「hello-world.cpp」サンプルファイルがあります。
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
次の方法で前処理されたファイルを作成します。
$ g++ -E hello-world.cpp -o hello-world.ii
そして、行数を確認してください。
$ wc -l hello-world.ii 17558 hello-world.ii
私のマシンには17,588行あります。 そのディレクトリでmake
を実行するだけで、これらの手順が実行されます。
コンパイラは、私たちが見ている単純なソースファイルよりもはるかに大きなファイルをコンパイルする必要があることがわかります。 これは、ヘッダーが含まれているためです。 この例では、ヘッダーを1つだけ含めています。 ヘッダーを含め続けると、翻訳単位はどんどん大きくなります。
この前処理とコンパイルのプロセスは、C言語の場合と同様です。 コンパイルのCルールに従い、ヘッダーファイルをインクルードしてオブジェクトコードを生成する方法はほぼ同じです。
ソースファイルがシンボルをインポートおよびエクスポートする方法
ここで、 cpp-article/symbols/c-vs-cpp-names
ディレクトリにあるファイルを見てみましょう。
2つの関数をエクスポートするsum.cという名前の単純なC(C ++ではない)ソースファイルがあります。1つは2つの整数を追加するためのもので、もう1つは2つのfloatを追加するためのものです。
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
それをコンパイルして(またはmake
と、実行する2つのサンプルアプリを作成するためのすべての手順を実行して)、sum.oオブジェクトファイルを作成します。
$ gcc -c sum.c
次に、このオブジェクトファイルによってエクスポートおよびインポートされたシンボルを確認します。
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
シンボルはインポートされず、 sumF
とsumI
の2つのシンボルがエクスポートされます。 これらのシンボルは、.textセグメント(T)の一部としてエクスポートされるため、関数名、実行可能コードです。
他の(CまたはC ++の両方の)ソースファイルがこれらの関数を呼び出したい場合は、呼び出す前にそれらを宣言する必要があります。
それを行う標準的な方法は、それらを宣言し、それらを呼び出したいソースファイルにインクルードするヘッダーファイルを作成することです。 ヘッダーには、任意の名前と拡張子を付けることができます。 私はsum.h
を選びました:
#ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
それらのifdef
/ endif
条件付きコンパイルブロックとは何ですか? Cソースファイルからこのヘッダーを含めると、次のようになります。
int sumI(int a, int b); float sumF(float a, float b);
しかし、C ++ソースファイルからそれらを含める場合は、次のようになります。
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
C言語はextern "C"
ディレクティブについて何も知りませんが、C ++は知っているので、このディレクティブをC関数宣言に適用する必要があります。 これは、C ++が関数/メソッドのオーバーロードをサポートしているのに対し、Cはサポートしていないため、関数(およびメソッド)の名前をマングルするためです。
これは、print.cppという名前のC++ソースファイルで確認できます。
#include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); }
同じ名前( printSum
)の2つの関数があり、パラメーターの型のみが異なりますint
またはfloat
です。 関数のオーバーロードは、Cには存在しないC ++機能です。この機能を実装し、これらの関数を区別するために、C ++は、エクスポートされたシンボル名に示されているように、関数名をマングルします(nmの出力から関連するものだけを選択します) :
$ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout
これらの関数は(私のシステムでは)floatバージョンの場合は_Z8printSumff
として、intバージョンの場合は_Z8printSumii
としてエクスポートされます。 C ++のすべての関数名は、 extern "C"
として宣言されていない限り、マングルされます。 print.cpp
でCリンケージを使用して宣言された関数は、 printSumInt
とprintSumFloat
の2つです。
したがって、それらをオーバーロードすることはできません。または、それらがマングルされていないため、エクスポートされた名前は同じになります。 名前の末尾にIntまたはFloatを付けて、それらを区別する必要がありました。
マングルされていないため、すぐにわかるように、Cコードから呼び出すことができます。
C ++ソースコードで表示されるようにマングルされた名前を表示するには、 nm
コマンドで-C
(デマングル)オプションを使用できます。 繰り返しますが、出力の同じ関連部分のみをコピーします。
$ nm -C print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T printSum(float, float) 0000000000000000 T printSum(int, int) U std::cout
このオプションを使用すると、_Z8printSumffの代わりに_Z8printSumff
printSum(float, float)
が表示され、 _ZSt4cout
の代わりにstd::coutが表示されます。これはより人間にわかりやすい名前です。
また、C ++コードがCコードを呼び出していることもわかりますprint.cpp
はsumI
とsumF
を呼び出しています。これらは、 sum.h
でCリンケージを持つと宣言されたC関数です。 これは、上記のprint.oのnm出力で確認できます。これは、いくつかの未定義(U)シンボル( sumF
、 sumI
、およびstd::cout
)を通知します。 これらの未定義のシンボルは、リンクフェーズで出力されたこのオブジェクトファイルと一緒にリンクされるオブジェクトファイル(またはライブラリ)の1つで提供されることになっています。
これまでのところ、ソースコードをオブジェクトコードにコンパイルしたばかりですが、まだリンクしていません。 これらのインポートされたシンボルの定義を含むオブジェクトファイルをこのオブジェクトファイルと一緒にリンクしない場合、リンカは「シンボルがありません」というエラーで停止します。
print.cpp
はC++ソースファイルであり、C ++コンパイラ(g ++)でコンパイルされているため、その中のすべてのコードはC++コードとしてコンパイルされていることにも注意してください。 printSumInt
やprintSumFloat
などのCリンケージを持つ関数も、C++機能を使用できるC++関数です。 シンボルの名前のみがCと互換性がありますが、コードはC ++です。これは、両方の関数がオーバーロードされた関数( printSum
)を呼び出していることからわかります。これは、 printSumInt
またはprintSumFloat
がCでコンパイルされた場合には発生しません。
ここで、 print.hpp
を見てみましょう。これは、CまたはC ++ソースファイルの両方からインクルードできるヘッダーファイルです。これにより、 printSumInt
とprintSumFloat
をCとC ++の両方から呼び出し、 printSum
をC++から呼び出すことができます。
#ifdef __cplusplus void printSum(int a, int b); void printSum(float a, float b); extern "C" { #endif void printSumInt(int a, int b); void printSumFloat(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
Cソースファイルからインクルードしている場合は、次のことを確認するだけです。
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
は、名前が変更されているためCコードからは見えません。そのため、Cコード用に宣言する(標準で移植可能な)方法はありません。 はい、私はそれらを次のように宣言することができます:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
そして、それは私の現在インストールされているコンパイラがそれのために発明した正確な名前なので、リンカーは文句を言いませんが、それがあなたのリンカー(あなたのコンパイラが別のマングル名を生成する場合)、あるいは私のリンカーの次のバージョン。 コンパイラ固有であり、CおよびC ++呼び出し(特にC ++関数)では異なる可能性のあるさまざまな呼び出し規約(パラメーターの受け渡し方法と戻り値の戻り方法)が存在するため、呼び出しが期待どおりに機能するかどうかさえわかりません。これはメンバー関数であり、このポインターをパラメーターとして受け取ります)。
コンパイラは、通常のC ++関数に対して1つの呼び出し規約を使用する可能性があり、外部「C」リンケージを持つと宣言されている場合は別の呼び出し規約を使用する可能性があります。 したがって、1つの関数が実際にはC ++を使用しているのに、C呼び出し規約を使用していると言ってコンパイラをだますと、コンパイルツールチェーンでそれぞれに使用される規約が異なる場合、予期しない結果が生じる可能性があります。
CコードとC++コードを混在させる標準的な方法があり、CからC ++オーバーロード関数を呼び出す標準的な方法は、 printSum
をprintSumInt
およびprintSumFloat
でラップする場合と同様に、Cリンケージを使用して関数でラップすることです。
C ++ソースファイルからprint.hpp
を含めると、 __cplusplus
プリプロセッサマクロが定義され、ファイルは次のように表示されます。
void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C"
これにより、C++コードはオーバーロードされた関数printSumまたはそのラッパーprintSumInt
およびprintSumFloat
を呼び出すことができます。
次に、プログラムのエントリポイントであるmain関数を含むCソースファイルを作成しましょう。 このCメイン関数はprintSumInt
とprintSumFloat
を呼び出します。つまり、Cリンケージを使用して両方のC++関数を呼び出します。 これらはC++関数(関数本体はC ++コードを実行します)であり、C++のマングル名しかありません。 ファイルの名前はc-main.c
です。
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
それをコンパイルしてオブジェクトファイルを生成します。
$ gcc -c c-main.c
そして、インポート/エクスポートされたシンボルを参照してください。
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
期待どおり、mainをエクスポートし、 printSumFloat
とprintSumInt
をインポートします。
リンクする少なくとも1つのファイルprint.o
がC++でコンパイルされているため、すべてを実行可能ファイルにリンクするには、C ++リンカー(g ++)を使用する必要があります。
$ g++ -o c-app sum.o print.o c-main.o
実行すると、期待される結果が得られます。
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
それでは、 cpp-main.cpp
という名前のC++メインファイルを試してみましょう。
#include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; }
cpp-main.o
オブジェクトファイルのインポート/エクスポートされたシンボルをコンパイルして確認します。
$ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int)
mainをエクスポートし、CリンケージprintSumFloat
とprintSumInt
、および両方のマングルバージョンのprintSum
をインポートします。
メインシンボルはC++ソースファイルであり、 extern "C"
として定義されていないため、このC ++ソースからmain(int, char**)
ようなマングルシンボルとしてエクスポートされないのはなぜか疑問に思われるかもしれません。 main
は特別な実装定義関数であり、私の実装では、CまたはC ++ソースファイルで定義されているかどうかに関係なく、Cリンケージを使用することを選択したようです。
プログラムをリンクして実行すると、期待される結果が得られます。
$ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8
ヘッダーガードのしくみ
これまでのところ、同じソースファイルから直接または間接的にヘッダーを2回含めないように注意してきました。 ただし、1つのヘッダーに他のヘッダーを含めることができるため、同じヘッダーを間接的に複数回含めることができます。 また、ヘッダーコンテンツは、ヘッダーコンテンツが含まれていた場所に挿入されるだけなので、重複した宣言で簡単に終了できます。
cpp-article/header-guards
のサンプルファイルを参照してください。
// unguarded.hpp class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; // guarded.hpp: #ifndef __GUARDED_HPP #define __GUARDED_HPP class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; #endif // __GUARDED_HPP
違いは、guarded.hppでは、 __GUARDED_HPP
プリプロセッサマクロが定義されていない場合にのみ含まれる条件でヘッダー全体を囲むことです。 プリプロセッサにこのファイルが初めて含まれるときは、定義されません。 ただし、マクロはその保護されたコード内で定義されているため、次に(同じソースファイルから直接または間接的に)インクルードされると、プリプロセッサは#ifndefと#endifの間の行を認識し、その間のすべてのコードを破棄します。彼ら。
このプロセスは、コンパイルするすべてのソースファイルで発生することに注意してください。 これは、このヘッダーファイルをソースファイルごとに1回だけインクルードできることを意味します。 あるソースファイルからインクルードされたという事実は、そのソースファイルがコンパイルされたときに別のソースファイルからインクルードされることを妨げることはありません。 同じソースファイルから複数回インクルードされるのを防ぐだけです。
サンプルファイルmain-guarded.cpp
には、 guarded.hpp
が2回含まれています。
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
ただし、前処理された出力には、クラスA
定義が1つしか表示されません。
$ g++ -E main-guarded.cpp # 1 "main-guarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-guarded.cpp" # 1 "guarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-guarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
したがって、問題なくコンパイルできます。
$ g++ -o guarded main-guarded.cpp
ただし、 main-unguarded.cpp
ファイルにはunguarded.hpp
が2回含まれています。
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
また、前処理された出力には、クラスAの2つの定義が示されています。
$ g++ -E main-unguarded.cpp # 1 "main-unguarded.cpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "main-unguarded.cpp" # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 2 "main-unguarded.cpp" 2 # 1 "unguarded.hpp" 1 class A { public: A(int a) : m_a(a) {} void setA(int a) { m_a = a; } int getA() const { return m_a; } private: int m_a; }; # 3 "main-unguarded.cpp" 2 int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
これにより、コンパイル時に問題が発生します。
$ g++ -o unguarded main-unguarded.cpp
main-unguarded.cpp:2:0
からインクルードされたファイル:
unguarded.hpp:1:7: error: redefinition of 'class A' class A { ^ In file included from main-unguarded.cpp:1:0: unguarded.hpp:1:7: error: previous definition of 'class A' class A { ^
簡潔にするために、ほとんどが短い例であるため、必要がない場合は、この記事では保護されたヘッダーを使用しません。 ただし、常にヘッダーファイルを保護してください。 どこからも含まれないソースファイルではありません。 ヘッダーファイルだけです。
パラメータの値と定数を渡す
cpp-article/symbols/pass-by
by-value.cpp
ファイルを見てください:
#include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
using namespace std
ディレクティブを使用しているので、変換ユニットの残りの部分(私の場合はソースファイルの残りの部分)のstd名前空間内のシンボル(関数またはクラス)の名前を修飾する必要はありません。 これがヘッダーファイルの場合、ヘッダーファイルは複数のソースファイルからインクルードされることになっているため、このディレクティブを挿入するべきではありませんでした。 このディレクティブは、各ソースファイルのグローバルスコープに、ヘッダーが含まれているポイントからstd名前空間全体をもたらします。

それらのファイルで私の後に含まれるヘッダーでさえ、スコープ内にそれらのシンボルがあります。 彼らはこれが起こることを予期していなかったので、これは名前の衝突を引き起こす可能性があります。 したがって、このディレクティブをヘッダーで使用しないでください。 必要に応じて、すべてのヘッダーを含めた後でのみ、ソースファイルで使用してください。
一部のパラメーターがconstであることに注意してください。 これは、関数の本体で変更しようとしても変更できないことを意味します。 コンパイルエラーが発生します。 また、このソースファイルのすべてのパラメーターは、参照(&)やポインター(*)ではなく、値によって渡されることに注意してください。 これは、呼び出し元がそれらのコピーを作成し、関数に渡すことを意味します。 したがって、呼び出し元がconstであるかどうかは関係ありません。関数本体で変更すると、呼び出し元が関数に渡した元の値ではなく、コピーのみが変更されるためです。
値(コピー)によって渡されるパラメーターの恒常性は呼び出し元にとって重要ではないため、オブジェクトコード(関連する出力のみ)をコンパイルおよび検査した後にわかるように、関数シグネチャでマングルされません。
$ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)
シグニチャは、コピーされたパラメータが関数の本体でconstであるかどうかを表しません。 関係ありません。 これらの値が変更されるかどうかを関数本体のリーダーに一目で示すために、関数定義のみが重要でした。 この例では、パラメーターの半分だけがconstとして宣言されているため、コントラストがわかりますが、const-correctにしたい場合は、関数本体で変更されていないため、すべてが宣言されている必要があります(そしてそれらはすべきではありません)。
呼び出し元に表示される関数宣言は重要ではないため、次のようにby-value.hpp
ヘッダーを作成できます。
#include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);
ここにconst修飾子を追加することは許可されていますが(定義にconstではないconst変数として修飾することもでき、機能します)、これは必須ではなく、宣言を不必要に冗長にするだけです。
参照渡し
by-reference.cpp
を見てみましょう:
#include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }
参照を渡すときの一貫性は、呼び出し元にとって重要です。これは、呼び出し元が引数を変更するかどうかを呼び出し元に通知するためです。 したがって、シンボルは一定の状態でエクスポートされます。
$ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)
これは、呼び出し元が使用するヘッダーにも反映されている必要があります。
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
これまで行ってきたように、宣言(ヘッダー)に変数の名前を記述しなかったことに注意してください。 この例と前の例では、これも合法です。 呼び出し元は変数にどのように名前を付けるかを知る必要がないため、宣言に変数名は必要ありません。 ただし、宣言ではパラメーター名が一般的に望ましいため、ユーザーは各パラメーターの意味、つまり呼び出しで何を送信するかを一目で知ることができます。
驚いたことに、関数の定義には変数名も必要ありません。 これらは、関数で実際にパラメーターを使用する場合にのみ必要です。 ただし、使用しない場合は、パラメータをタイプのままにして、名前を付けないでおくことができます。 関数が決して使用しないパラメーターを宣言するのはなぜですか? 関数(またはメソッド)は、オブザーバーに渡される特定のパラメーターを定義するコールバックインターフェイスのように、インターフェイスの一部にすぎない場合があります。 オブザーバーは、すべてのパラメーターが呼び出し元によって送信されるため、インターフェースが指定するすべてのパラメーターを使用してコールバックを作成する必要があります。 ただし、オブザーバーはそれらすべてに関心があるわけではないため、「未使用のパラメーター」に関するコンパイラー警告を受け取る代わりに、関数定義は名前を付けずにそのままにしておくことができます。
ポインタを渡す
// by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }
const要素(例ではint)へのポインターを宣言するには、次のいずれかとして型を宣言できます。
int const * const int *
ポインター自体もconstにしたい場合、つまり、ポインターを他の何かを指すように変更できない場合は、スターの後にconstを追加します。
int const * const const int * const
ポインタ自体をconstにしたいが、ポインタが指す要素は望まない場合:
int * const
関数のシグネチャをオブジェクトファイルのデマングルされた検査と比較します。
$ g++ -c by-pointer.cpp $ nm -C by-pointer.o 000000000000004a T sum(float*, float*) 0000000000000000 T sum(int const*, int const*) 0000000000000105 T sum(std::vector<float, std::allocator<float> > const*) 000000000000009c T sum(std::vector<int, std::allocator<int> > const*)
ご覧のとおり、 nm
ツールは最初の表記(タイプの後の定数)を使用します。 また、エクスポートされ、呼び出し元にとって重要な唯一の定数は、関数がポインターが指す要素を変更するかどうかであることに注意してください。 ポインタ自体は常にコピーとして渡されるため、ポインタ自体の恒常性は呼び出し元には関係ありません。 この関数は、呼び出し元には関係のない、別の場所を指すポインターの独自のコピーのみを作成できます。
したがって、ヘッダーファイルは次のように作成できます。
#include <vector> int sum(int const* a, int const* b); float sum(float* a, float* b); int sum(std::vector<int>* const); float sum(std::vector<float>* const);
ポインタによる受け渡しは、参照による受け渡しに似ています。 1つの違いは、参照を渡す場合、呼び出し元は有効な要素の参照を渡したと見なされ、NULLまたはその他の無効なアドレスを指していないのに対し、ポインターはNULLを指している可能性があることです。 NULLを渡すことが特別な意味を持つ場合、参照の代わりにポインタを使用できます。
C ++ 11の値は、移動セマンティクスでも渡すことができるためです。 このトピックはこの記事では扱われませんが、C++での引数の受け渡しなどの他の記事で調べることができます。
ここで取り上げないもう1つの関連トピックは、これらすべての関数を呼び出す方法です。 これらのヘッダーがすべてソースファイルから含まれているが呼び出されていない場合、コンパイルとリンケージは成功します。 ただし、すべての関数を呼び出したい場合は、一部の呼び出しがあいまいになるため、エラーが発生します。 コンパイラーは、特にコピーで渡すか参照(またはconst参照)で渡すかを選択するときに、特定の引数に対して複数のバージョンのsumを選択できます。 その分析は、この記事の範囲外です。
異なるフラグを使用したコンパイル
ここで、このテーマに関連する実際の状況を見てみましょう。ここでは、見つけにくいバグが発生する可能性があります。
ディレクトリcpp-article/diff-flags
に移動し、 Counters.hpp
を確認します。
class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; };
このクラスには2つのカウンターがあり、これらはゼロから始まり、インクリメントまたは読み取りが可能です。 NDEBUG
マクロが定義されていないビルドを呼び出す方法であるデバッグビルドの場合、他の2つのカウンターのいずれかがインクリメントされるたびにインクリメントされる3番目のカウンターも追加します。 これは、このクラスの一種のデバッグヘルパーになります。 多くのサードパーティライブラリクラスまたは組み込みのC++ヘッダー(コンパイラによって異なります)でさえ、このようなトリックを使用して、さまざまなレベルのデバッグを可能にします。 これにより、デバッグビルドは、範囲外になるイテレータや、ライブラリメーカーが考える可能性のあるその他の興味深いことを検出できます。 リリースビルドを「 NDEBUG
マクロが定義されているビルド」と呼びます。
リリースビルドの場合、プリコンパイル済みヘッダーは次のようになります( grep
を使用して空白行を削除します)。
$ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };
デバッグビルドの場合、次のようになります。
$ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };
前に説明したように、デバッグビルドにはもう1つのカウンターがあります。
また、いくつかのヘルパーファイルを作成しました。
// increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); }
// increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); }
// main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; }
また、 increment2.cpp
のコンパイラフラグのみをカスタマイズできるMakefile
:
all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags
それでは、 NDEBUG
を定義せずに、すべてをデバッグモードでコンパイルしましょう。
$ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o
今実行します:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
出力は期待どおりです。 次に、 NDEBUG
が定義されたファイルの1つだけをコンパイルします。これはリリースモードであり、何が起こるかを確認します。
$ make clean rm -f *.o diff-flags $ CFLAGS='-DNDEBUG' make g++ -c -O2 main.cpp g++ -c -DNDEBUG -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o $ ./diff-flags c.get1(): 0 c.get2(): 4 c.getDebugAllCounters(): 7
出力が期待どおりではありません。 increment1
1関数は、2つのintメンバーフィールドしかないCountersクラスのリリースバージョンを見ました。 So, it incremented the first field, thinking that it was m_counter1
, and didn't increment anything else since it knows nothing about the m_debugAllCounters
field. I say that increment1
incremented the counter because the inc1 method in Counter
is inline, so it was inlined in increment1
function body, not called from it. The compiler probably decided to inline it because the -O2
optimization level flag was used.
So, m_counter1
was never incremented and m_debugAllCounters
was incremented instead of it by mistake in increment1
. That's why we see 0 for m_counter1
but we still see 7 for m_debugAllCounters
.
Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector
, and others were compiled with those options.
Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.
This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector
to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).
The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.
The Compiler Does a Lot More Than You May Think
In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.
From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.
I hope you will find the knowledge from this article useful in your C++ projects.
Toptal Engineeringブログでさらに読む:
- CおよびC++言語を学ぶ方法:究極のリスト
- C#とC ++:コアとは何ですか?