C++ Nasıl Çalışır: Derlemeyi Anlama
Yayınlanan: 2022-03-11Bjarne Stroustrup'un C++ Programlama Dili'nde “C++ Turu: Temel Bilgiler”—Standart C++ başlıklı bir bölüm vardır. 2.2'deki bu bölüm, yarım sayfada C++'daki derleme ve bağlantı sürecinden bahseder. Derleme ve bağlama, C++ yazılım geliştirme sırasında her zaman meydana gelen çok temel iki süreçtir, ancak garip bir şekilde, birçok C++ geliştiricisi tarafından iyi anlaşılmazlar.
C++ kaynak kodu neden başlık ve kaynak dosyalara bölünmüştür? Her parça derleyici tarafından nasıl görülüyor? Bu, derlemeyi ve bağlamayı nasıl etkiler? Bunun gibi düşünmüş olabileceğiniz ancak bir gelenek olarak kabul ettiğiniz daha birçok soru var.
İster bir C++ uygulaması tasarlıyor, ister onun için yeni özellikler uyguluyor, hataları (özellikle belirli garip hatalar) gidermeye çalışıyor olun, ister C ve C++ kodunun birlikte çalışmasını sağlamaya çalışıyor olun, derleme ve bağlamanın nasıl çalıştığını bilmek size çok zaman kazandıracak ve bu görevleri çok daha keyifli hale getirin. Bu yazıda tam olarak bunu öğreneceksiniz.
Makale, bir C++ derleyicisinin bazı temel dil yapılarıyla nasıl çalıştığını açıklayacak, süreçleriyle ilgili bazı genel soruları yanıtlayacak ve geliştiricilerin C++ geliştirmede sıklıkla yaptığı bazı ilgili hataları gidermenize yardımcı olacaktır.
Not: Bu makale, https://bitbucket.org/danielmunoz/cpp-article adresinden indirilebilecek bazı örnek kaynak kodlarına sahiptir.
Örnekler bir CentOS Linux makinesinde derlendi:
$ uname -sr Linux 3.10.0-327.36.3.el7.x86_64
g++ sürümünü kullanma:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)
Sağlanan kaynak dosyalar diğer işletim sistemlerine taşınabilir olmalıdır, ancak otomatik oluşturma işlemi için onlara eşlik eden Makefile dosyaları yalnızca Unix benzeri sistemlere taşınabilir olmalıdır.
Derleme İşlem Hattı: Önişleme, Derleme ve Bağlantı
Her C++ kaynak dosyasının bir nesne dosyasında derlenmesi gerekir. Birden çok kaynak dosyanın derlenmesinden kaynaklanan nesne dosyaları daha sonra bir yürütülebilir dosyaya, paylaşılan bir kitaplığa veya statik bir kitaplığa bağlanır (bunların sonuncusu yalnızca nesne dosyalarının bir arşividir). C++ kaynak dosyaları genellikle .cpp, .cxx veya .cc uzantılı son eklere sahiptir.
Bir C++ kaynak dosyası, #include
yönergesiyle başlık dosyaları olarak bilinen diğer dosyaları içerebilir. Başlık dosyalarının .h, .hpp veya .hxx gibi uzantıları vardır veya C++ standart kitaplığında ve diğer kitaplıkların başlık dosyalarında (Qt gibi) olduğu gibi uzantıları yoktur. Uzantı, dahil edilen dosyanın tüm içeriğiyle #include
yönergesini içeren satırı tam anlamıyla değiştirecek olan C++ önişlemcisi için önemli değildir.
Derleyicinin bir kaynak dosya üzerinde yapacağı ilk adım, önişlemciyi bu dosya üzerinde çalıştırmaktır. Derleyiciye yalnızca kaynak dosyalar iletilir (önceden işlemek ve derlemek için). Başlık dosyaları derleyiciye iletilmez. Bunun yerine, kaynak dosyalardan dahil edilirler.
Her bir başlık dosyası, kaç tane kaynak dosyanın içerdiğine veya kaynak dosyalardan dahil edilen diğer kaç başlık dosyasının bunları içerdiğine bağlı olarak, tüm kaynak dosyaların ön işleme aşaması sırasında birden çok kez açılabilir (birçok dolaylılık düzeyi olabilir) . Kaynak dosyalar ise derleyici (ve önişlemci) tarafından kendisine iletildiğinde yalnızca bir kez açılır.
Her C++ kaynak dosyası için önişlemci, bir #include yönergesi bulduğunda içeriği ekleyerek bir çeviri birimi oluşturacak ve aynı zamanda koşullu derleme bulduğunda kaynak dosyadan ve başlıklardan kodu çıkaracaktır. yönergesi false
olarak değerlendirilen bloklar. Ayrıca makro değiştirmeler gibi diğer bazı görevleri de yapacak.
Önişlemci bu (bazen çok büyük) çeviri birimini oluşturmayı bitirdiğinde, derleyici derleme aşamasını başlatır ve nesne dosyasını üretir.
Bu çeviri birimini (önceden işlenmiş kaynak kodu) elde etmek için -E
seçeneği, önceden işlenmiş kaynak dosyanın istenen adını belirtmek için -o
seçeneğiyle birlikte g++ derleyicisine geçirilebilir.
cpp-article/hello-world
dizininde bir “hello-world.cpp” örnek dosyası vardır:
#include <iostream> int main(int argc, char* argv[]) { std::cout << "Hello world" << std::endl; return 0; }
Önceden işlenmiş dosyayı şu şekilde oluşturun:
$ g++ -E hello-world.cpp -o hello-world.ii
Ve satır sayısına bakın:
$ wc -l hello-world.ii 17558 hello-world.ii
Benim makinemde 17.588 hat var. Ayrıca bu dizinde make
çalıştırabilirsiniz ve bu adımları sizin için yapacaktır.
Derleyicinin gördüğümüz basit kaynak dosyadan çok daha büyük bir dosya derlemesi gerektiğini görebiliriz. Bunun nedeni, eklenen başlıklardır. Ve örneğimizde sadece bir başlık ekledik. Başlıkları eklemeye devam ettikçe çeviri birimi daha da büyüyor.
Bu önişlem ve derleme işlemi, C dili için benzerdir. Derleme için C kurallarını takip eder ve başlık dosyalarını içerme ve nesne kodu üretme şekli hemen hemen aynıdır.
Kaynak Dosyalar Sembolleri Nasıl İçe ve Dışa Aktarır?
Şimdi cpp-article/symbols/c-vs-cpp-names
dizinindeki dosyaları görelim.
Biri iki tamsayı eklemek için diğeri iki kayan nokta eklemek için olmak üzere iki işlevi dışa aktaran sum.c adında basit bir C (C++ değil) kaynak dosyası vardır:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
sum.o nesne dosyasını oluşturmak için onu derleyin (veya make
ve yürütülecek iki örnek uygulamayı oluşturmak için tüm adımları çalıştırın):
$ gcc -c sum.c
Şimdi bu nesne dosyası tarafından dışa aktarılan ve içe aktarılan sembollere bakın:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
Hiçbir simge içe aktarılmaz ve iki simge dışa aktarılır: sumF
ve sumI
. Bu semboller, .text segmentinin (T) bir parçası olarak dışa aktarılır, bu nedenle işlev adları, yürütülebilir kodlardır.
Diğer (hem C hem de C++) kaynak dosyaları bu işlevleri çağırmak isterse, çağırmadan önce bunları bildirmeleri gerekir.
Bunu yapmanın standart yolu, onları bildiren ve onları çağırmak istediğimiz kaynak dosyaya dahil eden bir başlık dosyası oluşturmaktır. Başlık herhangi bir ad ve uzantıya sahip olabilir. sum.h
seçtim:
#ifdef __cplusplus extern "C" { #endif int sumI(int a, int b); float sumF(float a, float b); #ifdef __cplusplus } // end extern "C" #endif
Bu ifdef
/ endif
koşullu derleme blokları nelerdir? Bu başlığı bir C kaynak dosyasından eklersem, şöyle olmasını istiyorum:
int sumI(int a, int b); float sumF(float a, float b);
Ancak bunları bir C++ kaynak dosyasından eklersem, şöyle olmasını istiyorum:
extern "C" { int sumI(int a, int b); float sumF(float a, float b); } // end extern "C"
C dili, extern "C"
yönergesi hakkında hiçbir şey bilmez, ancak C++ bilir ve bu yönergenin C işlev bildirimlerine uygulanması gerekir. Bunun nedeni, C++'ın işlev (ve yöntem) adlarını, işlev/yöntem aşırı yüklemesini desteklediği ve C'nin desteklemediği için yönetmesidir.
Bu, print.cpp adlı C++ kaynak dosyasında görülebilir:
#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); }
Aynı ada sahip ( printSum
) iki işlev vardır ve bunlar yalnızca parametrelerinin tipinde farklılık gösterir: int
veya float
. İşlev aşırı yüklemesi, C'de bulunmayan bir C++ özelliğidir. Bu özelliği uygulamak ve bu işlevleri ayırt etmek için, C++, dışa aktarılan sembol adlarında görebileceğimiz gibi işlev adını değiştirir (yalnızca nm'nin çıktısından ilgili olanı seçeceğim) :
$ 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
Bu işlevler (benim sistemimde) kayan sürüm için _Z8printSumff
ve int sürümü için _Z8printSumii olarak dışa _Z8printSumii
. C++'daki her işlev adı, extern "C"
olarak bildirilmedikçe karıştırılır. print.cpp
C bağlantısıyla bildirilen iki işlev vardır: printSumInt
ve printSumFloat
.
Bu nedenle, aşırı yüklenemezler veya karışık olmadıkları için dışa aktarılan adları aynı olur. Adlarının sonuna bir Int veya Float ekleyerek onları birbirinden ayırt etmem gerekiyordu.
Karışık olmadıkları için, yakında göreceğimiz gibi, C kodundan çağrılabilirler.
Karışık isimleri C++ kaynak kodunda gördüğümüz gibi görmek için nm
komutunda -C
(demangle) seçeneğini kullanabiliriz. Yine, çıktının yalnızca aynı ilgili bölümünü kopyalayacağım:
$ 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
Bu seçenekle, _Z8printSumff yerine _Z8printSumff
printSum(float, float)
görüyoruz ve _ZSt4cout
yerine daha insan dostu isimler olan std::cout görüyoruz.
Ayrıca C++ kodumuzun C kodunu çağırdığını görüyoruz: print.cpp
, sum.h içinde C bağlantısına sahip olduğu bildirilen C fonksiyonları olan sumI
ve sumF
sum.h
. Bu, bazı tanımsız (U) semboller hakkında bilgi veren yukarıdaki print.o nm çıktısında görülebilir: sumF
, sumI
ve std::cout
. Bu tanımsız sembollerin, bağlantı aşamasında bu nesne dosyası çıktısı ile birlikte bağlanacak olan nesne dosyalarından (veya kitaplıklardan) birinde sağlanması gerekir.
Şimdiye kadar kaynak kodu nesne kodunda derledik, henüz bağlantı kurmadık. İçe aktarılan sembollerin tanımlarını içeren nesne dosyasını bu nesne dosyasıyla birlikte bağlamazsak, bağlayıcı “eksik sembol” hatasıyla duracaktır.
Ayrıca print.cpp
bir C++ derleyicisi (g++) ile derlenmiş bir C++ kaynak dosyası olduğundan, içindeki tüm kodun C++ kodu olarak derlendiğini unutmayın. printSumInt
ve printSumFloat
gibi C bağlantılı işlevler de C++ özelliklerini kullanabilen C++ işlevleridir. Yalnızca simgelerin adları C ile uyumludur, ancak kod C++'dır; bu, her iki işlevin de aşırı yüklenmiş bir işlevi ( printSum
) çağırdığı gerçeğiyle görülebilir; bu, printSumInt
veya printSumFloat
C'de derlenmişse gerçekleşemez.
Şimdi hem C hem de C++ kaynak dosyalarından eklenebilen, printSumInt
ve printSumFloat
hem C'den hem de C++'dan çağrılmasına ve printSum
C++'dan çağrılmasına olanak tanıyan bir başlık dosyası olan print.hpp
bakalım:
#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
Bir C kaynak dosyasından ekliyorsak, sadece şunu görmek isteriz:
void printSumInt(int a, int b); void printSumFloat(float a, float b);
printSum
, adı karışık olduğu için C kodundan görülemez, bu nedenle onu C kodu için bildirmek için (standart ve taşınabilir) bir yolumuz yok. Evet, onları şu şekilde ilan edebilirim:
void _Z8printSumii(int a, int b); void _Z8printSumff(float a, float b);
Ve bağlayıcı, şu anda kurulu derleyicimin onun için icat ettiği tam ad olduğundan şikayet etmeyecek, ancak bağlayıcınız için çalışıp çalışmayacağını (derleyiciniz farklı bir karışık ad oluşturuyorsa) veya hatta bunun için çalışıp çalışmayacağını bilmiyorum. bağlayıcımın sonraki sürümü. Derleyiciye özgü olan ve C ve C++ çağrıları için (özellikle C++ işlevleri için) farklı olabilecek farklı çağrı kurallarının (parametrelerin nasıl geçirildiği ve dönüş değerlerinin nasıl döndürüldüğü) varlığından çağrının beklendiği gibi çalışıp çalışmayacağını bile bilmiyorum. üye işlevlerdir ve bu işaretçiyi parametre olarak alırlar).
Derleyiciniz, normal C++ işlevleri için potansiyel olarak bir çağrı kuralı ve harici “C” bağlantısına sahip olarak bildirilmişlerse farklı bir çağrı kuralı kullanabilir. Bu nedenle, bir işlevin C çağırma kuralını kullandığını söyleyerek derleyiciyi aldatmak, aslında C++ kullandığı için, derleme araç zincirinizde her biri için kullanılan kurallar farklıysa beklenmedik sonuçlar verebilir.
C ve C++ kodunu karıştırmanın standart yolları vardır ve C'den C++ aşırı yüklenmiş işlevleri çağırmanın standart bir yolu, printSum'u printSumInt
ve printSum
ile sarmalayarak yaptığımız gibi, bunları C bağlantısıyla işlevlere printSumFloat
.
print.hpp
bir C++ kaynak dosyasından eklersek, __cplusplus
önişlemci makrosu tanımlanacak ve dosya şu şekilde görünecektir:
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"
Bu, C++ kodunun aşırı yüklenmiş printSum işlevini veya onun sarmalayıcılarını printSumInt
ve printSumFloat
olarak çağırmasına izin verir.
Şimdi bir programın giriş noktası olan ana işlevi içeren bir C kaynak dosyası oluşturalım. Bu C ana işlevi printSumInt
ve printSumFloat
, yani C bağlantısıyla her iki C++ işlevini de çağıracaktır. Unutmayın, bunlar yalnızca C++ karışık adları olmayan C++ işlevleridir (işlev gövdeleri C++ kodunu yürütür). Dosyanın adı c-main.c
:
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }
Nesne dosyasını oluşturmak için derleyin:
$ gcc -c c-main.c
Ve içe/dışa aktarılan sembollere bakın:
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt
Beklendiği gibi main dışa aktarır ve printSumFloat
ve printSumInt
içe aktarır.
Hepsini yürütülebilir bir dosyaya bağlamak için C++ bağlayıcısını (g++) kullanmamız gerekir, çünkü bağlayacağımız en az bir dosya print.o
C++ ile derlenmiştir:
$ g++ -o c-app sum.o print.o c-main.o
Yürütme beklenen sonucu üretir:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
Şimdi cpp-main.cpp
adlı bir C++ ana dosyasıyla deneyelim:
#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
nesne dosyasının içe/dışa aktarılan sembollerini derleyin ve görün:
$ 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'i dışa aktarır ve C bağlantısı printSumFloat
ve printSumInt
ve printSum'un her iki karışık sürümünü printSum
.
Bir C++ kaynak dosyası olduğundan ve extern "C"
olarak tanımlanmadığından, ana sembolün neden bu C++ kaynağından main(int, char**)
gibi karışık bir sembol olarak dışa aktarılmadığını merak ediyor olabilirsiniz. main
, özel bir uygulama tanımlı işlevdir ve uygulamam, bir C veya C++ kaynak dosyasında tanımlanmış olup olmadığına bakılmaksızın, bunun için C bağlantısını kullanmayı seçmiş gibi görünüyor.
Programı bağlamak ve çalıştırmak, beklenen sonucu verir:
$ 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
Başlık Muhafızları Nasıl Çalışır?
Şimdiye kadar, başlıklarımı aynı kaynak dosyadan doğrudan veya dolaylı olarak iki kez eklememeye dikkat ettim. Ancak bir başlık diğer başlıkları içerebildiğinden, aynı başlık dolaylı olarak birden çok kez dahil edilebilir. Ve başlık içeriği, eklendiği yere yeni eklendiğinden, yinelenen bildirimlerle sonlandırmak kolaydır.
cpp-article/header-guards
içindeki örnek dosyalara bakın.
// 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
Aradaki fark, guarded.hpp'de, tüm üstbilgiyi yalnızca __GUARDED_HPP
önişlemci makrosu tanımlanmadığında dahil edilecek bir koşulla çevrelememizdir. Önişlemci bu dosyayı ilk kez içerdiğinde tanımlanmayacaktır. Ancak, makro bu korunan kodun içinde tanımlandığından, bir sonraki eklendiğinde (doğrudan veya dolaylı olarak aynı kaynak dosyadan), önişlemci #ifndef ve #endif arasındaki satırları görecek ve aradaki tüm kodu atacaktır. onlara.
Bu işlemin derlediğimiz her kaynak dosya için gerçekleştiğini unutmayın. Bu, bu başlık dosyasının her kaynak dosya için yalnızca bir kez eklenebileceği anlamına gelir. Bir kaynak dosyadan alınmış olması, o kaynak dosya derlendiğinde farklı bir kaynak dosyadan eklenmesine engel olmaz. Aynı kaynak dosyadan bir kereden fazla eklenmesini engeller.
main-guarded.cpp
örnek dosyası, guarded.hpp
iki kez içerir:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Ancak önceden işlenmiş çıktı, A
sınıfının yalnızca bir tanımını gösterir:
$ 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(); }
Bu nedenle, sorunsuz bir şekilde derlenebilir:
$ g++ -o guarded main-guarded.cpp
Ancak main-unguarded.cpp
dosyası iki kez unguarded.hpp
içerir:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
Ve önceden işlenmiş çıktı, A sınıfının iki tanımını gösterir:
$ 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(); }
Bu, derleme sırasında sorunlara neden olur:

$ g++ -o unguarded main-unguarded.cpp
main-unguarded.cpp:2:0
dahil edilen dosyada:
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 { ^
Kısa olması adına, çoğu kısa örnek olduğu için gerekli değilse bu makalede korumalı başlıklar kullanmayacağım. Ancak her zaman başlık dosyalarınızı koruyun. Hiçbir yerden eklenmeyecek olan kaynak dosyalarınız değil. Sadece başlık dosyaları.
Değer ve Parametre Tutarlılığına Göre Geçiş
cpp-article/symbols/pass-by
içindeki by-value.cpp
dosyasına bakın:
#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
yönergesini kullandığım için, çeviri biriminin geri kalanında, benim durumumda kaynak dosyanın geri kalanı olan std ad alanı içindeki sembol adlarını (işlevler veya sınıflar) nitelemem gerekmiyor. Bu bir başlık dosyası olsaydı, bu yönergeyi eklememeliydim çünkü bir başlık dosyasının birden çok kaynak dosyadan eklenmesi gerekiyordu; bu yönerge, başlığımı içerdikleri noktadan itibaren tüm std ad alanını her kaynak dosyanın genel kapsamına getirir.
Bu dosyalara benimkilerden sonra dahil edilen başlıklar bile bu sembollere sahip olacaktır. Bunun olmasını beklemedikleri için bu, isim çakışmalarına neden olabilir. Bu nedenle, bu yönergeyi başlıklarda kullanmayın. İsterseniz yalnızca kaynak dosyalarda ve yalnızca tüm başlıkları ekledikten sonra kullanın.
Bazı parametrelerin const nasıl olduğuna dikkat edin. Bu, eğer denersek, işlevin gövdesinde değiştirilemeyecekleri anlamına gelir. Derleme hatası veriyordu. Ayrıca, bu kaynak dosyadaki tüm parametrelerin referans (&) veya işaretçi (*) ile değil, değere göre iletildiğini unutmayın. Bu, arayanın bunların bir kopyasını oluşturacağı ve işleve geçeceği anlamına gelir. Bu nedenle, arayan için bunların const olup olmadığı önemli değildir, çünkü bunları işlev gövdesinde değiştirirsek, arayanın işleve ilettiği orijinal değeri değil, yalnızca kopyayı değiştiririz.
Değer (kopya) ile iletilen bir parametrenin sabitliği arayan için önemli olmadığından, nesne kodunun derlenmesi ve incelenmesinden sonra görülebileceği gibi işlev imzasında karıştırılmaz (yalnızca ilgili çıktı):
$ 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> >)
İmzalar, kopyalanan parametrelerin işlevin gövdelerinde const olup olmadığını ifade etmez. Önemli değil. İşlev gövdesinin okuyucusuna bu değerlerin değişip değişmeyeceğini bir bakışta göstermek yalnızca işlev tanımı için önemliydi. Örnekte, parametrelerin sadece yarısı const olarak bildirildi, bu nedenle karşıtlığı görebiliriz, ancak const-doğru olmak istiyorsak, bunların hiçbiri işlev gövdesinde değiştirilmediğinden (ve bunlar yapmamalı).
Çağıranın gördüğü fonksiyon bildirimi için önemli olmadığı için, by-value.hpp
başlığını şu şekilde oluşturabiliriz:
#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);
Burada const niteleyicilerinin eklenmesine izin verilir (tanımda const olmayan const değişkenleri olarak bile nitelendirebilirsiniz ve işe yarar), ancak bu gerekli değildir ve yalnızca bildirimleri gereksiz yere ayrıntılı hale getirir.
Referansa Göre Geç
by-reference.cpp
bakalım:
#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); }
Referans ile geçerken sabitlik arayan için önemlidir, çünkü arayan kişiye argümanının aranan tarafından değiştirilip değiştirilmeyeceğini söyler. Bu nedenle, semboller sabitlikleri ile dışa aktarılır:
$ 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&)
Bu, arayanların kullanacağı başlığa da yansıtılmalıdır:
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);
Şu ana kadar yaptığım gibi, bildirimlerde (başlık kısmında) değişkenlerin adını yazmadığımı unutmayın. Bu, bu örnek ve öncekiler için de yasaldır. Çağıranın değişkeninizi nasıl adlandırmak istediğinizi bilmesi gerekmediğinden, bildirimde değişken adları gerekli değildir. Ancak parametre adları genellikle bildirimlerde istenir, böylece kullanıcı bir bakışta her parametrenin ne anlama geldiğini ve dolayısıyla çağrıda ne göndereceğini bilebilir.
Şaşırtıcı bir şekilde, bir işlevin tanımında değişken adlarına da gerek yoktur. Yalnızca işlevde parametreyi gerçekten kullanırsanız gereklidir. Ancak hiç kullanmazsanız, parametreyi türle ancak ad olmadan bırakabilirsiniz. Bir işlev neden asla kullanmayacağı bir parametre bildirir? Bazen işlevler (veya yöntemler), gözlemciye iletilen belirli parametreleri tanımlayan geri arama arabirimi gibi bir arabirimin yalnızca bir parçasıdır. Arayan tarafından gönderileceğinden, gözlemci, arabirimin belirttiği tüm parametrelerle bir geri arama oluşturmalıdır. Ancak gözlemci hepsiyle ilgilenmeyebilir, bu nedenle “kullanılmayan bir parametre” hakkında derleyici uyarısı almak yerine işlev tanımı onu isimsiz bırakabilir.
İşaretçiden Geç
// 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); }
Bir const öğesine işaretçi bildirmek için (örnekte int), türü aşağıdakilerden biri olarak bildirebilirsiniz:
int const * const int *
İşaretçinin kendisinin de const olmasını istiyorsanız, yani işaretçi başka bir şeye işaret edecek şekilde değiştirilemezse, yıldızdan sonra bir const eklersiniz:
int const * const const int * const
İşaretçinin kendisinin const olmasını, ancak işaret ettiği öğenin olmamasını istiyorsanız:
int * const
İşlev imzalarını nesne dosyasının düzensiz incelemesiyle karşılaştırın:
$ 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*)
Gördüğünüz gibi, nm
aracı ilk gösterimi (türden sonra const) kullanır. Ayrıca, dışa aktarılan ve arayan için önemli olan tek sabitliğin, işlevin işaretçi tarafından gösterilen öğeyi değiştirip değiştirmeyeceği olduğuna dikkat edin. İşaretçinin kendisi her zaman bir kopya olarak iletildiğinden, işaretçinin kendisinin sabitliği arayan için önemsizdir. İşlev, arayan için alakasız olan, başka bir yere işaret etmek için yalnızca işaretçinin kendi kopyasını oluşturabilir.
Böylece, bir başlık dosyası şu şekilde oluşturulabilir:
#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);
İşaretçiden geçmek, referanstan geçmek gibidir. Bir fark, referansla geçtiğinizde, arayanın NULL'u veya başka bir geçersiz adresi göstermeden geçerli bir öğenin referansını geçtiği ve varsayılmasının beklenmesi ve bir işaretçinin örneğin NULL'a işaret edebilmesidir. NULL geçişinin özel bir anlamı olduğunda referanslar yerine işaretçiler kullanılabilir.
C++ 11 değerleri hareket semantiği ile de iletilebilir. Bu konu bu makalede ele alınmayacaktır, ancak C++'da Argüman Geçişi gibi diğer makalelerde incelenebilir.
Burada ele alınmayacak bir diğer ilgili konu, tüm bu işlevlerin nasıl çağrılacağıdır. Tüm bu başlıklar bir kaynak dosyadan dahil edilmişse ancak çağrılmamışsa, derleme ve bağlantı başarılı olacaktır. Ancak tüm fonksiyonları çağırmak isterseniz, bazı aramalar belirsiz olacağından bazı hatalar olacaktır. Derleyici, özellikle kopyayla mı yoksa referansla mı (veya sabit referansla) iletileceğini seçerken, belirli argümanlar için birden fazla toplam sürümü seçebilecektir. Bu analiz bu makalenin kapsamı dışındadır.
Farklı Bayraklarla Derleme
Şimdi bu konu ile ilgili bulunması zor bugların ortaya çıkabileceği gerçek hayattan bir duruma bakalım.
cpp-article/diff-flags
dizinine gidin ve Counters.hpp
bakın:
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; };
Bu sınıfın sıfır ile başlayan ve artırılabilen veya okunabilen iki sayacı vardır. NDEBUG
makrosunun tanımlanmadığı yapıları bu şekilde arayacağım hata ayıklama yapıları için, diğer iki sayaçtan herhangi biri artırıldığında artırılacak olan üçüncü bir sayaç da ekliyorum. Bu, bu sınıf için bir tür hata ayıklama yardımcısı olacaktır. Birçok üçüncü taraf kitaplık sınıfı ve hatta yerleşik C++ üstbilgileri (derleyiciye bağlı olarak) farklı düzeylerde hata ayıklamaya izin vermek için bunun gibi hileler kullanır. Bu, hata ayıklama yapılarının aralık dışına çıkan yineleyicileri ve kitaplık oluşturucunun düşünebileceği diğer ilginç şeyleri algılamasına olanak tanır. Yayın yapılarını " NDEBUG
makrosunun tanımlandığı yapılar" olarak adlandıracağım.
Sürüm derlemeleri için önceden derlenmiş başlık şöyle görünür (boş satırları kaldırmak için grep
kullanıyorum):
$ 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; };
Hata ayıklama derlemeleri için şöyle görünür:
$ 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; };
Daha önce açıkladığım gibi, hata ayıklama yapılarında bir sayaç daha var.
Ayrıca bazı yardımcı dosyalar oluşturdum.
// 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; }
Ve yalnızca increment2.cpp
için derleyici bayraklarını özelleştirebilen bir 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
Öyleyse, NDEBUG tanımlamadan hepsini hata ayıklama modunda 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
Şimdi çalıştırın:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7
Çıktı tam olarak beklendiği gibi. Şimdi NDEBUG
tanımlı dosyalardan sadece birini yayınlayalım ve ne olacağını görelim:
$ 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
Çıktı beklendiği gibi değil. 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.
Toptal Mühendislik Blogunda Daha Fazla Okuma:
- C ve C++ Dilleri Nasıl Öğrenilir: Nihai Liste
- C# ve C++: Çekirdekte Neler Var?