Cara Kerja C++: Memahami Kompilasi
Diterbitkan: 2022-03-11Bahasa Pemrograman C++ Bjarne Stroustrup memiliki bab berjudul "Tur C++: Dasar-Dasar"—C++ Standar. Bab itu, di 2.2, menyebutkan dalam setengah halaman proses kompilasi dan penautan dalam C++. Kompilasi dan penautan adalah dua proses yang sangat mendasar yang terjadi sepanjang waktu selama pengembangan perangkat lunak C++, tetapi anehnya, keduanya tidak dipahami dengan baik oleh banyak pengembang C++.
Mengapa kode sumber C++ dibagi menjadi file header dan sumber? Bagaimana setiap bagian dilihat oleh kompiler? Bagaimana pengaruhnya terhadap kompilasi dan penautan? Ada banyak lagi pertanyaan seperti ini yang mungkin telah Anda pikirkan tetapi telah diterima sebagai kesepakatan.
Baik Anda mendesain aplikasi C++, mengimplementasikan fitur baru untuknya, mencoba mengatasi bug (terutama bug aneh tertentu), atau mencoba membuat kode C dan C++ bekerja bersama, mengetahui cara kerja kompilasi dan penautan akan menghemat banyak waktu dan membuat tugas-tugas itu jauh lebih menyenangkan. Dalam artikel ini, Anda akan mempelajarinya dengan tepat.
Artikel ini akan menjelaskan cara kerja kompiler C++ dengan beberapa konstruksi bahasa dasar, menjawab beberapa pertanyaan umum yang terkait dengan prosesnya, dan membantu Anda mengatasi beberapa kesalahan terkait yang sering dilakukan pengembang dalam pengembangan C++.
Catatan: Artikel ini memiliki beberapa contoh kode sumber yang dapat diunduh dari https://bitbucket.org/danielmunoz/cpp-article
Contoh-contoh dikompilasi dalam mesin CentOS Linux:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
Menggunakan versi g++:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
File sumber yang disediakan harus portabel untuk sistem operasi lain, meskipun Makefile yang menyertainya untuk proses pembuatan otomatis harus portabel hanya untuk sistem mirip Unix.
Build Pipeline: Praproses, Kompilasi, dan Tautan
Setiap file sumber C++ perlu dikompilasi menjadi file objek. File objek yang dihasilkan dari kompilasi beberapa file sumber kemudian ditautkan ke executable, shared library, atau static library (yang terakhir hanya arsip file objek). File sumber C++ umumnya memiliki akhiran ekstensi .cpp, .cxx atau .cc.
File sumber C++ dapat menyertakan file lain, yang dikenal sebagai file header, dengan #include
directive. File header memiliki ekstensi seperti .h, .hpp, atau .hxx, atau tidak memiliki ekstensi sama sekali seperti di library standar C++ dan file header library lain (seperti Qt). Ekstensi tidak masalah untuk preprosesor C++, yang secara harfiah akan menggantikan baris yang berisi arahan #include
dengan seluruh konten file yang disertakan.
Langkah pertama yang akan dilakukan kompiler pada file sumber adalah menjalankan praprosesor di atasnya. Hanya file sumber yang diteruskan ke kompiler (untuk melakukan praproses dan mengompilasinya). File header tidak diteruskan ke kompiler. Sebaliknya, mereka disertakan dari file sumber.
Setiap file header dapat dibuka beberapa kali selama fase prapemrosesan semua file sumber, tergantung pada berapa banyak file sumber yang menyertakannya, atau berapa banyak file header lain yang disertakan dari file sumber juga menyertakannya (mungkin ada banyak tingkat tipuan) . File sumber, di sisi lain, dibuka hanya sekali oleh kompiler (dan praprosesor), ketika diteruskan ke sana.
Untuk setiap file sumber C++, praprosesor akan membangun unit terjemahan dengan menyisipkan konten di dalamnya ketika menemukan #include direktif pada saat yang sama akan menghapus kode dari file sumber dan header ketika menemukan kompilasi bersyarat blok yang arahannya bernilai false
. Itu juga akan melakukan beberapa tugas lain seperti penggantian makro.
Setelah praprosesor selesai membuat unit terjemahan (terkadang besar), kompiler memulai fase kompilasi dan menghasilkan file objek.
Untuk mendapatkan unit terjemahan tersebut (kode sumber yang telah diproses sebelumnya), opsi -E
dapat diteruskan ke kompiler g++, bersama dengan opsi -o
untuk menentukan nama yang diinginkan dari file sumber yang telah diproses sebelumnya.
Di direktori cpp-article/hello-world
, ada file contoh "hello-world.cpp":
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
Buat file yang telah diproses sebelumnya dengan:
$ g++ -E hello-world.cpp -o hello-world.ii
Dan lihat jumlah baris:
$ wc -l hello-world.ii 17558 hello-world.ii
Ini memiliki 17.588 baris di mesin saya. Anda juga dapat menjalankan make
di direktori itu dan itu akan melakukan langkah-langkah itu untuk Anda.
Kita dapat melihat bahwa kompiler harus mengkompilasi file yang jauh lebih besar daripada file sumber sederhana yang kita lihat. Ini karena header yang disertakan. Dan dalam contoh kami, kami hanya menyertakan satu header. Unit terjemahan menjadi lebih besar dan lebih besar karena kami terus menyertakan header.
Proses praproses dan kompilasi ini serupa untuk bahasa C. Ini mengikuti aturan C untuk kompilasi, dan cara menyertakan file header dan menghasilkan kode objek hampir sama.
Bagaimana File Sumber Mengimpor dan Mengekspor Simbol
Mari kita lihat sekarang file-file di direktori cpp-article/symbols/c-vs-cpp-names
.
Ada file sumber C (bukan C++) sederhana bernama sum.c yang mengekspor dua fungsi, satu untuk menambahkan dua bilangan bulat dan satu untuk menambahkan dua float:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
Kompilasi (atau jalankan make
dan semua langkah untuk membuat dua contoh aplikasi yang akan dieksekusi) untuk membuat file objek sum.o:
$ gcc -c sum.c
Sekarang lihat simbol yang diekspor dan diimpor oleh file objek ini:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
Tidak ada simbol yang diimpor dan dua simbol diekspor: sumF
dan sumI
. Simbol-simbol tersebut diekspor sebagai bagian dari segmen .text (T), jadi mereka adalah nama fungsi, kode yang dapat dieksekusi.
Jika file sumber lain (baik C atau C++) ingin memanggil fungsi tersebut, mereka harus mendeklarasikannya sebelum memanggil.
Cara standar untuk melakukannya adalah dengan membuat file header yang mendeklarasikannya dan memasukkannya ke dalam file sumber apa pun yang ingin kita panggil. Header dapat memiliki nama dan ekstensi apa pun. Saya memilih 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
Apa blok kompilasi bersyarat ifdef
/ endif
itu? Jika saya menyertakan header ini dari file sumber C, saya ingin menjadi:
int sumI(int a, int b); float sumF(float a, float b);
Tetapi jika saya memasukkannya dari file sumber C++, saya ingin menjadi:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
Bahasa C tidak tahu apa-apa tentang direktif extern "C"
, tetapi C++ tahu, dan perlu direktif ini diterapkan pada deklarasi fungsi C. Ini karena C++ menamai fungsi (dan metode) karena mendukung fungsi/metode yang berlebihan, sedangkan C tidak.
Ini dapat dilihat di file sumber C++ bernama print.cpp:
#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); }
Ada dua fungsi dengan nama yang sama ( printSum
) yang hanya berbeda dalam tipe parameternya: int
atau float
. Fungsi overloading adalah fitur C++ yang tidak ada di C. Untuk mengimplementasikan fitur ini dan membedakan fungsi-fungsi tersebut, C++ merusak nama fungsi, seperti yang dapat kita lihat pada nama simbol yang diekspor (saya hanya akan memilih apa yang relevan dari output 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
Fungsi-fungsi tersebut diekspor (di sistem saya) sebagai _Z8printSumff
untuk versi float dan _Z8printSumii
untuk versi int. Setiap nama fungsi dalam C++ rusak kecuali dinyatakan sebagai extern "C"
. Ada dua fungsi yang dideklarasikan dengan C linkage di print.cpp
: printSumInt
dan printSumFloat
.
Oleh karena itu, mereka tidak dapat kelebihan beban, atau nama yang diekspor akan sama karena tidak rusak. Saya harus membedakan mereka satu sama lain dengan menambahkan Int atau Float di akhir nama mereka.
Karena mereka tidak hancur, mereka dapat dipanggil dari kode C, seperti yang akan segera kita lihat.
Untuk melihat nama yang rusak seperti yang kita lihat dalam kode sumber C++, kita dapat menggunakan opsi -C
(demangle) pada perintah nm
. Sekali lagi, saya hanya akan menyalin bagian relevan yang sama dari output:
$ 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
Dengan opsi ini, alih-alih _Z8printSumff
kita melihat printSum(float, float)
, dan alih-alih _ZSt4cout
kita melihat std::cout, yang merupakan nama yang lebih ramah manusia.
Kami juga melihat bahwa kode C++ kami memanggil kode C: print.cpp
memanggil sumI
dan sumF
, yang merupakan fungsi C yang dideklarasikan memiliki hubungan C di sum.h
. Hal ini dapat dilihat pada nm output print.o di atas, yang menginformasikan beberapa simbol yang tidak terdefinisi (U): sumF
, sumI
dan std::cout
. Simbol yang tidak ditentukan tersebut seharusnya disediakan di salah satu file objek (atau pustaka) yang akan ditautkan bersama dengan output file objek ini dalam fase tautan.
Sejauh ini kami baru saja mengkompilasi kode sumber menjadi kode objek, kami belum menautkannya. Jika kami tidak menautkan file objek yang berisi definisi untuk simbol yang diimpor tersebut bersama dengan file objek ini, penaut akan berhenti dengan kesalahan "simbol yang hilang".
Perhatikan juga bahwa karena print.cpp
adalah file sumber C++, dikompilasi dengan kompiler C++ (g++), semua kode di dalamnya dikompilasi sebagai kode C++. Fungsi dengan tautan C seperti printSumInt
dan printSumFloat
juga merupakan fungsi C++ yang dapat menggunakan fitur C++. Hanya nama simbol yang kompatibel dengan C, tetapi kodenya adalah C++, yang dapat dilihat dari fakta bahwa kedua fungsi memanggil fungsi yang kelebihan beban ( printSum
), yang tidak dapat terjadi jika printSumInt
atau printSumFloat
dikompilasi dalam C.
Mari kita lihat sekarang print.hpp
, file header yang dapat disertakan baik dari file sumber C atau C++, yang memungkinkan printSumInt
dan printSumFloat
dipanggil baik dari C maupun dari C++, dan printSum
dipanggil dari 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
Jika kami memasukkannya dari file sumber C, kami hanya ingin melihat:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
tidak dapat dilihat dari kode C karena namanya rusak, jadi kami tidak memiliki cara (standar dan portabel) untuk mendeklarasikannya untuk kode C. Ya, saya dapat mendeklarasikannya sebagai:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
Dan tautan tidak akan mengeluh karena itulah nama persis yang diciptakan oleh kompiler saya yang saat ini diinstal untuknya, tetapi saya tidak tahu apakah itu akan berfungsi untuk tautan Anda (jika kompiler Anda menghasilkan nama rusak yang berbeda), atau bahkan untuk versi berikutnya dari tautan saya. Saya bahkan tidak tahu apakah panggilan akan berfungsi seperti yang diharapkan karena adanya konvensi panggilan yang berbeda (bagaimana parameter dilewatkan dan nilai yang dikembalikan dikembalikan) yang spesifik untuk kompiler dan mungkin berbeda untuk panggilan C dan C++ (terutama untuk fungsi C++ yang merupakan fungsi anggota dan menerima pointer ini sebagai parameter).
Kompiler Anda berpotensi menggunakan satu konvensi pemanggilan untuk fungsi C++ biasa dan yang berbeda jika dideklarasikan memiliki hubungan "C" eksternal. Jadi, menipu kompiler dengan mengatakan bahwa satu fungsi menggunakan konvensi pemanggilan C sementara sebenarnya menggunakan C++ karena itu dapat memberikan hasil yang tidak terduga jika konvensi yang digunakan untuk masing-masing kebetulan berbeda di toolchain kompilasi Anda.
Ada cara standar untuk mencampur kode C dan C++ dan cara standar untuk memanggil fungsi kelebihan beban C++ dari C adalah dengan membungkusnya dalam fungsi dengan hubungan C seperti yang kita lakukan dengan membungkus printSum
dengan printSumInt
dan printSumFloat
.
Jika kita menyertakan print.hpp
dari file sumber C++, makro praprosesor __cplusplus
akan ditentukan dan file akan terlihat sebagai:
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"
Ini akan memungkinkan kode C++ untuk memanggil fungsi yang kelebihan beban printSum atau pembungkusnya printSumInt
dan printSumFloat
.
Sekarang mari kita buat file sumber C yang berisi fungsi utama, yang merupakan titik masuk untuk sebuah program. Fungsi utama C ini akan memanggil printSumInt
dan printSumFloat
, yaitu, akan memanggil kedua fungsi C++ dengan tautan C. Ingat, itu adalah fungsi C++ (badan fungsinya mengeksekusi kode C++) yang hanya tidak memiliki nama C++ yang rusak. File tersebut bernama c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
Kompilasi untuk menghasilkan file objek:
$ gcc -c c-main.c
Dan lihat simbol yang diimpor/diekspor:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
Ini mengekspor main dan mengimpor printSumFloat
dan printSumInt
, seperti yang diharapkan.
Untuk menautkan semuanya menjadi file yang dapat dieksekusi, kita perlu menggunakan tautan C++ (g++), karena setidaknya satu file yang akan kita tautkan, print.o
, dikompilasi dalam C++:
$ g++ -o c-app sum.o print.o c-main.o
Eksekusi menghasilkan hasil yang diharapkan:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
Sekarang mari kita coba dengan file utama C++, bernama cpp-main.cpp
:
#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; }
Kompilasi dan lihat simbol yang diimpor/diekspor dari file objek 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)
Ini mengekspor main dan mengimpor C linkage printSumFloat
dan printSumInt
, dan keduanya versi printSum
yang rusak .
Anda mungkin bertanya-tanya mengapa simbol utama tidak diekspor sebagai simbol yang rusak seperti main(int, char**)
dari sumber C++ ini karena ini adalah file sumber C++ dan tidak didefinisikan sebagai extern "C"
. Yah, main
adalah fungsi yang ditentukan implementasi khusus dan implementasi saya tampaknya telah memilih untuk menggunakan tautan C untuk itu tidak peduli apakah itu didefinisikan dalam file sumber C atau C++.
Menautkan dan menjalankan program memberikan hasil yang diharapkan:
$ 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
Bagaimana Pengawal Header Bekerja
Sejauh ini, saya berhati-hati untuk tidak memasukkan header saya dua kali, secara langsung atau tidak langsung, dari file sumber yang sama. Tetapi karena satu tajuk dapat menyertakan tajuk lain, tajuk yang sama secara tidak langsung dapat dimasukkan beberapa kali. Dan karena konten header baru saja disisipkan di tempat asalnya, mudah untuk mengakhiri dengan deklarasi duplikat.
Lihat contoh file di 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
Perbedaannya adalah, di guarded.hpp, kami mengelilingi seluruh header dalam kondisi yang hanya akan disertakan jika __GUARDED_HPP
makro praprosesor tidak ditentukan. Saat pertama kali praprosesor menyertakan file ini, file tersebut tidak akan ditentukan. Namun, karena makro didefinisikan di dalam kode yang dijaga itu, saat berikutnya disertakan (dari file sumber yang sama, secara langsung atau tidak langsung), praprosesor akan melihat garis antara #ifndef dan #endif dan akan membuang semua kode di antara mereka.
Perhatikan bahwa proses ini terjadi untuk setiap file sumber yang kami kompilasi. Artinya, file header ini dapat dimasukkan sekali dan hanya sekali untuk setiap file sumber. Fakta bahwa itu disertakan dari satu file sumber tidak akan mencegahnya untuk disertakan dari file sumber yang berbeda ketika file sumber itu dikompilasi. Itu hanya akan mencegahnya dimasukkan lebih dari sekali dari file sumber yang sama.
Contoh file main-guarded.cpp
menyertakan guarded.hpp
dua kali:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Tetapi output yang telah diproses sebelumnya hanya menunjukkan satu definisi kelas A
:
$ 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(); }
Oleh karena itu, dapat dikompilasi tanpa masalah:
$ g++ -o guarded main-guarded.cpp
Tetapi file main-unguarded.cpp
menyertakan unguarded.hpp
dua kali:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Dan output yang telah diproses sebelumnya menunjukkan dua definisi kelas A:
$ 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(); }
Ini akan menyebabkan masalah saat kompilasi:

$ g++ -o unguarded main-unguarded.cpp
Dalam file yang disertakan dari 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 { ^
Demi singkatnya, saya tidak akan menggunakan header yang dijaga dalam artikel ini jika tidak diperlukan karena sebagian besar adalah contoh singkat. Tapi selalu jaga file header Anda. Bukan file sumber Anda, yang tidak akan disertakan dari mana pun. Hanya file header.
Lewati Nilai dan Keteguhan Parameter
Lihat file by-value.cpp
di cpp-article/symbols/pass-by
:
#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); }
Karena saya menggunakan direktif using namespace std
, saya tidak harus memenuhi syarat nama simbol (fungsi atau kelas) di dalam namespace std di sisa unit terjemahan, yang dalam kasus saya adalah sisa file sumber. Jika ini adalah file header, saya seharusnya tidak memasukkan arahan ini karena file header seharusnya disertakan dari beberapa file sumber; arahan ini akan membawa ke lingkup global setiap file sumber seluruh ruang nama std dari titik mereka menyertakan header saya.
Bahkan tajuk yang disertakan setelah milik saya di file-file itu akan memiliki simbol-simbol itu dalam ruang lingkup. Ini dapat menghasilkan bentrokan nama karena mereka tidak mengharapkan ini terjadi. Karena itu, jangan gunakan arahan ini di header. Gunakan hanya di file sumber jika Anda mau, dan hanya setelah Anda menyertakan semua header.
Perhatikan bagaimana beberapa parameter adalah const. Ini berarti bahwa mereka tidak dapat diubah dalam tubuh fungsi jika kita mencobanya. Itu akan memberikan kesalahan kompilasi. Juga, perhatikan bahwa semua parameter dalam file sumber ini diteruskan dengan nilai, bukan dengan referensi (&) atau dengan penunjuk (*). Ini berarti bahwa pemanggil akan membuat salinannya dan meneruskan ke fungsi tersebut. Jadi, tidak masalah bagi pemanggil apakah itu const atau bukan, karena jika kita memodifikasinya di badan fungsi, kita hanya akan memodifikasi salinannya, bukan nilai asli yang diteruskan pemanggil ke fungsi tersebut.
Karena keteguhan parameter yang diteruskan oleh nilai (salinan) tidak masalah bagi pemanggil, itu tidak rusak dalam tanda tangan fungsi, karena dapat dilihat setelah mengkompilasi dan memeriksa kode objek (hanya output yang relevan):
$ 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> >)
Tanda tangan tidak mengungkapkan apakah parameter yang disalin adalah const atau tidak di badan fungsi. Tidak masalah. Penting untuk definisi fungsi saja, untuk menunjukkan sekilas kepada pembaca badan fungsi apakah nilai-nilai itu akan pernah berubah. Dalam contoh, hanya setengah dari parameter yang dideklarasikan sebagai const, sehingga kita dapat melihat kontrasnya, tetapi jika kita ingin menjadi const-correct, semuanya harus dideklarasikan karena tidak ada satupun yang dimodifikasi di badan fungsi (dan mereka seharusnya tidak).
Karena tidak masalah dengan deklarasi fungsi yang dilihat oleh pemanggil, kita dapat membuat header by-value.hpp
seperti ini:
#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);
Menambahkan qualifier const di sini diperbolehkan (Anda bahkan dapat memenuhi syarat sebagai variabel const yang bukan const dalam definisi dan itu akan berfungsi), tetapi ini tidak perlu dan itu hanya akan membuat deklarasi tidak perlu bertele-tele.
Lewati Referensi
Mari kita lihat 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); }
Keteguhan saat melewati referensi penting bagi penelepon, karena itu akan memberi tahu pemanggil apakah argumennya akan diubah atau tidak oleh yang dipanggil. Oleh karena itu, simbol diekspor dengan keteguhannya:
$ 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&)
Itu juga harus tercermin dalam tajuk yang akan digunakan penelepon:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
Perhatikan bahwa saya tidak menulis nama variabel dalam deklarasi (di header) seperti yang telah saya lakukan sejauh ini. Ini juga sah, untuk contoh ini dan untuk yang sebelumnya. Nama variabel tidak diperlukan dalam deklarasi, karena pemanggil tidak perlu tahu bagaimana Anda ingin memberi nama variabel Anda. Tetapi nama parameter umumnya diinginkan dalam deklarasi sehingga pengguna dapat mengetahui sekilas apa arti setiap parameter dan oleh karena itu apa yang harus dikirim dalam panggilan.
Anehnya, nama variabel juga tidak diperlukan dalam definisi suatu fungsi. Mereka hanya diperlukan jika Anda benar-benar menggunakan parameter dalam fungsi. Tetapi jika Anda tidak pernah menggunakannya, Anda dapat meninggalkan parameter dengan tipe tetapi tanpa nama. Mengapa suatu fungsi mendeklarasikan parameter yang tidak akan pernah digunakannya? Terkadang fungsi (atau metode) hanyalah bagian dari antarmuka, seperti antarmuka panggilan balik, yang mendefinisikan parameter tertentu yang diteruskan ke pengamat. Pengamat harus membuat panggilan balik dengan semua parameter yang ditentukan antarmuka karena semuanya akan dikirim oleh pemanggil. Tetapi pengamat mungkin tidak tertarik pada semuanya, jadi alih-alih menerima peringatan kompiler tentang "parameter yang tidak digunakan", definisi fungsi dapat membiarkannya begitu saja tanpa nama.
Lewati Pointer
// 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); }
Untuk mendeklarasikan pointer ke elemen const (int dalam contoh), Anda dapat mendeklarasikan tipe sebagai salah satu dari:
int const * const int *
Jika Anda juga ingin pointer itu sendiri menjadi const, yaitu pointer tidak dapat diubah untuk menunjuk ke sesuatu yang lain, Anda menambahkan const setelah bintang:
int const * const const int * const
Jika Anda ingin pointer itu sendiri menjadi const, tetapi bukan elemen yang ditunjuk olehnya:
int * const
Bandingkan tanda tangan fungsi dengan pemeriksaan demangled dari file objek:
$ 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*)
Seperti yang Anda lihat, alat nm
menggunakan notasi pertama (const setelah tipe). Juga, perhatikan bahwa satu-satunya constness yang diekspor, dan penting bagi pemanggil, adalah apakah fungsi tersebut akan memodifikasi elemen yang ditunjuk oleh pointer atau tidak. Keteguhan penunjuk itu sendiri tidak relevan untuk pemanggil karena penunjuk itu sendiri selalu diteruskan sebagai salinan. Fungsi hanya dapat membuat salinan pointernya sendiri untuk menunjuk ke tempat lain, yang tidak relevan untuk pemanggil.
Jadi, file header dapat dibuat sebagai:
#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);
Melewati pointer seperti melewati referensi. Satu perbedaan adalah bahwa ketika Anda melewati referensi, penelepon diharapkan dan dianggap telah melewati referensi elemen yang valid, tidak menunjuk ke NULL atau alamat tidak valid lainnya, sementara pointer dapat menunjuk ke NULL misalnya. Pointer dapat digunakan sebagai pengganti referensi ketika melewati NULL memiliki arti khusus.
Karena nilai C++11 juga dapat diteruskan dengan semantik bergerak. Topik ini tidak akan dibahas dalam artikel ini tetapi dapat dipelajari di artikel lain seperti Argument Passing di C++.
Topik terkait lainnya yang tidak akan dibahas di sini adalah bagaimana memanggil semua fungsi tersebut. Jika semua header tersebut disertakan dari file sumber tetapi tidak dipanggil, kompilasi dan tautan akan berhasil. Tetapi jika Anda ingin memanggil semua fungsi, akan ada beberapa kesalahan karena beberapa panggilan akan ambigu. Kompilator akan dapat memilih lebih dari satu versi penjumlahan untuk argumen tertentu, terutama ketika memilih apakah akan melewati salinan atau dengan referensi (atau referensi const). Analisis itu di luar cakupan artikel ini.
Kompilasi dengan Bendera yang Berbeda
Mari kita lihat, sekarang, situasi kehidupan nyata yang terkait dengan subjek ini di mana bug yang sulit ditemukan dapat muncul.
Pergi ke direktori cpp-article/diff-flags
dan lihat 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; };
Kelas ini memiliki dua penghitung, yang dimulai dari nol dan dapat ditambah atau dibaca. Untuk build debug, yang merupakan cara saya memanggil build di mana makro NDEBUG
tidak ditentukan, saya juga menambahkan penghitung ketiga, yang akan bertambah setiap kali salah satu dari dua penghitung lainnya bertambah. Itu akan menjadi semacam pembantu debug untuk kelas ini. Banyak kelas perpustakaan pihak ketiga atau bahkan header C++ bawaan (bergantung pada kompilernya) menggunakan trik seperti ini untuk memungkinkan tingkat debug yang berbeda. Ini memungkinkan build debug untuk mendeteksi iterator yang keluar dari jangkauan dan hal-hal menarik lainnya yang dapat dipikirkan oleh pembuat perpustakaan. Saya akan menyebut build rilis "build tempat makro NDEBUG
didefinisikan."
Untuk build rilis, header yang dikompilasi terlihat seperti (saya menggunakan grep
untuk menghapus baris kosong):
$ 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; };
Sedangkan untuk build debug, tampilannya akan seperti ini:
$ 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; };
Ada satu counter lagi di build debug, seperti yang saya jelaskan sebelumnya.
Saya juga membuat beberapa file pembantu.
// 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; }
Dan Makefile
yang dapat menyesuaikan flag compiler hanya untuk increment2.cpp
:
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
Jadi, mari kita kompilasi semuanya dalam mode debug, tanpa mendefinisikan 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
Sekarang jalankan:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
Outputnya seperti yang diharapkan. Sekarang mari kita kompilasi salah satu file dengan NDEBUG
yang ditentukan, yang akan menjadi mode rilis, dan lihat apa yang terjadi:
$ 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
Outputnya tidak seperti yang diharapkan. increment1
function saw a release version of the Counters class, in which there are only two int member fields. 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.
Bacaan Lebih Lanjut di Blog Teknik Toptal:
- Cara Mempelajari Bahasa C dan C++: Daftar Utama
- C# vs. C++: Apa Inti?