C++ ทำงานอย่างไร: ทำความเข้าใจกับการรวบรวม
เผยแพร่แล้ว: 2022-03-11ภาษาการเขียนโปรแกรม C++ ของ Bjarne Stroustrup มีบทที่ชื่อว่า “A Tour of C++: The Basics”—Standard C++ ในบทนั้นใน 2.2 กล่าวถึงกระบวนการรวบรวมและเชื่อมโยงในครึ่งหน้าใน C ++ การคอมไพล์และการเชื่อมโยงเป็นกระบวนการพื้นฐานสองกระบวนการที่เกิดขึ้นตลอดเวลาระหว่างการพัฒนาซอฟต์แวร์ C++ แต่ที่น่าแปลกก็คือ นักพัฒนา C++ หลายๆ คนไม่เข้าใจกระบวนการเหล่านี้ดีนัก
เหตุใดซอร์สโค้ด C ++ จึงถูกแบ่งออกเป็นส่วนหัวและไฟล์ต้นฉบับ คอมไพเลอร์มองเห็นแต่ละส่วนอย่างไร? มันส่งผลต่อการคอมไพล์และการเชื่อมโยงอย่างไร? มีคำถามเช่นนี้อีกมากที่คุณอาจเคยนึกถึงแต่ก็ยอมรับตามแบบแผน
ไม่ว่าคุณจะออกแบบแอปพลิเคชัน C++, ใช้งานคุณสมบัติใหม่, พยายามแก้ไขจุดบกพร่อง (โดยเฉพาะจุดบกพร่องแปลก ๆ บางอย่าง) หรือพยายามทำให้รหัส C และ C++ ทำงานร่วมกัน การรู้ว่าการรวบรวมและเชื่อมโยงทำงานอย่างไร จะช่วยคุณประหยัดเวลาได้มาก และ ทำให้งานเหล่านั้นน่าพอใจยิ่งขึ้น ในบทความนี้ คุณจะได้เรียนรู้ว่า
บทความนี้จะอธิบายวิธีที่คอมไพเลอร์ C++ ทำงานกับโครงสร้างภาษาพื้นฐานบางตัว ตอบคำถามทั่วไปที่เกี่ยวข้องกับกระบวนการ และช่วยคุณแก้ไขข้อผิดพลาดที่เกี่ยวข้องซึ่งนักพัฒนามักทำในการพัฒนา C++
หมายเหตุ: บทความนี้มีตัวอย่างซอร์สโค้ดบางส่วนที่สามารถดาวน์โหลดได้จาก https://bitbucket.org/danielmunoz/cpp-article
ตัวอย่างถูกคอมไพล์ในเครื่อง CentOS Linux:
$ 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)
ไฟล์ต้นฉบับที่ให้มาควรพกพาไปยังระบบปฏิบัติการอื่น แม้ว่า Makefiles ที่มากับพวกเขาสำหรับกระบวนการสร้างอัตโนมัติควรพกพาได้เฉพาะกับระบบที่เหมือน Unix เท่านั้น
Build Pipeline: ประมวลผลล่วงหน้า คอมไพล์ และลิงก์
ไฟล์ต้นฉบับ C++ แต่ละไฟล์จะต้องถูกคอมไพล์เป็นไฟล์อ็อบเจ็กต์ อ็อบเจ็กต์ไฟล์ที่เกิดจากการคอมไพล์ของไฟล์ต้นทางหลายไฟล์จะถูกลิงก์ไปยังไฟล์สั่งการ ไลบรารีที่ใช้ร่วมกัน หรือไลบรารีสแตติก (ไฟล์สุดท้ายเป็นเพียงไฟล์เก็บถาวรของไฟล์อ็อบเจ็กต์) ไฟล์ต้นฉบับ C++ จะมีนามสกุล .cpp, .cxx หรือ .cc
ไฟล์ต้นทาง C++ สามารถรวมไฟล์อื่นๆ ที่เรียกว่าไฟล์ส่วนหัว โดยมีคำสั่ง #include
ไฟล์ส่วนหัวมีนามสกุล เช่น .h, .hpp หรือ .hxx หรือไม่มีนามสกุลเหมือนในไลบรารีมาตรฐาน C++ และไฟล์ส่วนหัวของไลบรารีอื่นๆ (เช่น Qt) ส่วนขยายไม่สำคัญสำหรับตัวประมวลผลล่วงหน้า 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
บนไดเร็กทอรีนั้น และมันจะทำตามขั้นตอนเหล่านั้นให้กับคุณ
เราจะเห็นว่าคอมไพเลอร์ต้องคอมไพล์ไฟล์ที่มีขนาดใหญ่กว่าซอร์สไฟล์ธรรมดาที่เราเห็น นี่เป็นเพราะส่วนหัวที่รวมอยู่ และในตัวอย่างของเรา เราได้รวมเพียงส่วนหัวเดียว หน่วยการแปลจะใหญ่ขึ้นเรื่อยๆ เมื่อเรารวมส่วนหัวไว้ด้วย
กระบวนการพรีโพรเซสและคอมไพล์นี้คล้ายกับภาษาซี มันเป็นไปตามกฎ C สำหรับการคอมไพล์ และวิธีที่มันรวมไฟล์ส่วนหัวและสร้างโค้ดอ็อบเจ็กต์นั้นเกือบจะเหมือนกัน
วิธีที่ไฟล์ต้นทางนำเข้าและส่งออกสัญลักษณ์
มาดูไฟล์ในไดเร็กทอรี cpp-article/symbols/c-vs-cpp-names
มีไฟล์ต้นฉบับ C (ไม่ใช่ C++) ชื่อ sum.c ที่ส่งออกสองฟังก์ชัน ไฟล์หนึ่งสำหรับเพิ่มจำนวนเต็มสองจำนวนและอีกไฟล์หนึ่งสำหรับเพิ่มโฟลตสองตัว:
int sumI(int a, int b) { return a + b; } float sumF(float a, float b) { return a + b; }
คอมไพล์มัน (หรือรัน make
และขั้นตอนทั้งหมดเพื่อสร้างสองแอพตัวอย่างที่จะรัน) เพื่อสร้างไฟล์อ็อบเจ็กต์ sum.o:
$ gcc -c sum.c
ตอนนี้ดูสัญลักษณ์ที่ส่งออกและนำเข้าโดยไฟล์วัตถุนี้:
$ nm sum.o 0000000000000014 T sumF 0000000000000000 T sumI
ไม่มีการนำเข้าสัญลักษณ์และส่งออกสองสัญลักษณ์: sumF
และ sumI
สัญลักษณ์เหล่านั้นจะถูกส่งออกโดยเป็นส่วนหนึ่งของ .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 ไม่รองรับ
ซึ่งสามารถเห็นได้ในไฟล์ต้นฉบับ C++ ชื่อ 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); }
มีสองฟังก์ชันที่มีชื่อเดียวกัน ( printSum
) ซึ่งแตกต่างกันเฉพาะในประเภทพารามิเตอร์เท่านั้น: 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
ฟังก์ชั่นเหล่านั้นจะถูกส่งออก (ในระบบของฉัน) เป็น _Z8printSumff
สำหรับรุ่น float และ _Z8printSumii
สำหรับรุ่น int ทุกชื่อฟังก์ชันใน C++ จะถูกทำลายเว้นแต่จะประกาศเป็น extern "C"
มีสองฟังก์ชันที่ประกาศด้วยการเชื่อมโยง C ใน print.cpp
: printSumInt
และ printSumFloat
ดังนั้นจึงไม่สามารถโอเวอร์โหลดได้ มิฉะนั้นชื่อที่ส่งออกจะเหมือนกันเนื่องจากไม่ได้ถูกทำลาย ฉันต้องแยกความแตกต่างออกจากกันโดย postfixing Int หรือ Float ไว้ที่ท้ายชื่อ
เนื่องจากพวกมันไม่ถูกทำลายจึงสามารถเรียกจากรหัส C ได้ดังที่เราจะได้เห็นกันในไม่ช้า
หากต้องการดูชื่อที่สับสนเหมือนที่เราเห็นในซอร์สโค้ด C++ เราสามารถใช้ตัวเลือก -C
(demangle) ในคำสั่ง nm
อีกครั้ง ฉันจะคัดลอกเฉพาะส่วนที่เกี่ยวข้องของผลลัพธ์เท่านั้น:
$ 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
เราจะเห็น printSum(float, float)
และแทนที่จะเป็น _ZSt4cout
เราจะเห็น std::cout ซึ่งเป็นชื่อที่เป็นมิตรกับมนุษย์มากกว่า
นอกจากนี้เรายังเห็นว่ารหัส C++ ของเรากำลังเรียกรหัส C: print.cpp
กำลังเรียก sumI
และ sumF
ซึ่งเป็นฟังก์ชัน C ที่ประกาศว่ามีการเชื่อมโยง C ใน sum.h
สิ่งนี้สามารถเห็นได้ในเอาต์พุต nm ของ print.o ด้านบน ซึ่งแจ้งสัญลักษณ์ที่ไม่ได้กำหนด (U) บางส่วน: sumF
, sumI
และ std::cout
สัญลักษณ์ที่ไม่ได้กำหนดเหล่านี้ควรจะมีอยู่ในหนึ่งในไฟล์อ็อบเจ็กต์ (หรือไลบรารี) ที่จะเชื่อมโยงกับเอาต์พุตไฟล์อ็อบเจ็กต์นี้ในเฟสลิงก์
จนถึงตอนนี้ เราเพิ่งรวบรวมซอร์สโค้ดเป็นอ็อบเจ็กต์โค้ด เรายังไม่ได้เชื่อมโยง หากเราไม่เชื่อมโยงไฟล์อ็อบเจ็กต์ที่มีคำจำกัดความสำหรับสัญลักษณ์ที่นำเข้าเหล่านั้นร่วมกับไฟล์อ็อบเจ็กต์นี้ ตัวเชื่อมโยงจะหยุดโดยมีข้อผิดพลาด "สัญลักษณ์หายไป"
โปรดทราบว่าเนื่องจาก print.cpp
เป็นไฟล์ต้นฉบับ C++ ซึ่งคอมไพล์ด้วยคอมไพเลอร์ C++ (g++) โค้ดทั้งหมดในไฟล์จึงถูกคอมไพล์เป็นโค้ด C++ ฟังก์ชันที่มีการเชื่อมโยง C เช่น printSumInt
และ printSumFloat
ยังเป็นฟังก์ชัน 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 ++ ปกติและแบบอื่นหากมีการประกาศว่ามีการเชื่อมโยงภายนอก "C" ดังนั้น การโกงคอมไพเลอร์โดยบอกว่าฟังก์ชันหนึ่งใช้แบบแผนการเรียก C ในขณะที่ใช้ C ++ จริง ๆ เพราะมันสามารถให้ผลลัพธ์ที่ไม่คาดคิดได้หากแบบแผนที่ใช้สำหรับแต่ละแบบแตกต่างกันในการคอมไพล์ toolchain ของคุณ
มีวิธีมาตรฐานในการผสมโค้ด C และ C++ และวิธีมาตรฐานในการเรียกใช้ฟังก์ชันที่โอเวอร์โหลด C++ จาก C คือการห่อไว้ในฟังก์ชันที่มีการเชื่อมโยง C เหมือนกับที่เราทำโดยการห่อ printSum
ด้วย printSumInt
และ printSumFloat
หากเรารวม print.hpp
จากไฟล์ต้นฉบับ C++ มาโครตัวประมวลผลล่วงหน้า __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 หรือ wrappers printSumInt
และ printSumFloat
ตอนนี้ มาสร้างไฟล์ต้นฉบับ 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
มันส่งออกหลักและนำเข้า printSumFloat
และ printSumInt
ตามที่คาดไว้
ในการลิงก์ทั้งหมดเข้าด้วยกันเป็นไฟล์เรียกทำงาน เราจำเป็นต้องใช้ตัวเชื่อมโยง C++ (g++) เนื่องจากไฟล์ที่เราจะเชื่อมโยงอย่างน้อยหนึ่งไฟล์ print.o
ถูกคอมไพล์ด้วย C++:
$ g++ -o c-app sum.o print.o c-main.o
การดำเนินการให้ผลลัพธ์ที่คาดหวัง:
$ ./c-app 1 + 2 = 3 1.5 + 2.5 = 4
คราวนี้มาลองกับไฟล์หลัก C++ ชื่อ 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; }
คอมไพล์และดูสัญลักษณ์ที่นำเข้า/ส่งออกของไฟล์อ็อบเจ็กต์ 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)
มันส่งออกหลักและนำเข้า C เชื่อมโยง printSumFloat
และ printSumInt
และ printSum
เวอร์ชันที่มีปัญหาทั้งสอง
คุณอาจสงสัยว่าเหตุใดสัญลักษณ์หลักจึงไม่ถูกส่งออกเป็นสัญลักษณ์ที่ถูกทำลาย เช่น main(int, char**)
จากแหล่ง C++ นี้ เนื่องจากเป็นไฟล์ต้นฉบับ C++ และไม่ได้กำหนดเป็น extern "C"
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
Header Guards ทำงานอย่างไร
จนถึงตอนนี้ ฉันได้ระมัดระวังที่จะไม่รวมส่วนหัวของฉันสองครั้ง ไม่ว่าทางตรงหรือทางอ้อม จากไฟล์ต้นฉบับเดียวกัน แต่เนื่องจากส่วนหัวหนึ่งสามารถรวมส่วนหัวอื่นๆ ได้ จึงสามารถรวมส่วนหัวเดียวกันได้หลายครั้งโดยทางอ้อม และเนื่องจากเนื้อหาส่วนหัวถูกแทรกในตำแหน่งที่รวมไว้ จึงง่ายที่จะลงท้ายด้วยการประกาศที่ซ้ำกัน
ดูตัวอย่างไฟล์ใน 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 และจะละทิ้งรหัสทั้งหมดระหว่าง พวกเขา.
โปรดทราบว่ากระบวนการนี้เกิดขึ้นกับไฟล์ต้นฉบับทุกไฟล์ที่เราคอมไพล์ หมายความว่าไฟล์ส่วนหัวนี้สามารถรวมได้เพียงครั้งเดียวสำหรับแต่ละไฟล์ต้นฉบับ ความจริงที่ว่ามันถูกรวมจากไฟล์ต้นทางหนึ่งไฟล์จะไม่ป้องกันการรวมจากไฟล์ต้นทางอื่นเมื่อรวบรวมไฟล์ต้นฉบับนั้น มันจะป้องกันไม่ให้รวมมากกว่าหนึ่งครั้งจากไฟล์ต้นฉบับเดียวกัน
ไฟล์ตัวอย่าง main-guarded.cpp
มี guarded.hpp
สองครั้ง:
#include "guarded.hpp" #include "guarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
แต่ผลลัพธ์ที่ประมวลผลล่วงหน้าแสดงเพียงหนึ่งคำจำกัดความของคลาส 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(); }
จึงสามารถคอมไพล์ได้โดยไม่มีปัญหา:
$ g++ -o guarded main-guarded.cpp
แต่ไฟล์ main-unguarded.cpp
รวม unguarded.hpp
สองครั้ง:
#include "unguarded.hpp" #include "unguarded.hpp" int main(int argc, char* argv[]) { A a(5); a.setA(0); return a.getA(); }
และผลลัพธ์ที่ประมวลผลล่วงหน้าแสดงคำจำกัดความของคลาส 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(); }
ซึ่งจะทำให้เกิดปัญหาในการคอมไพล์:
$ 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 { ^
เพื่อความกระชับ ฉันจะไม่ใช้ส่วนหัวที่มีการป้องกันในบทความนี้หากไม่จำเป็น เนื่องจากส่วนใหญ่เป็นเพียงตัวอย่างสั้นๆ แต่ปกป้องไฟล์ส่วนหัวของคุณเสมอ ไม่ใช่ไฟล์ต้นทางของคุณ ซึ่งจะไม่ถูกรวมจากทุกที่ แค่ไฟล์ส่วนหัว

ผ่านค่าและความคงตัวของพารามิเตอร์
ดูไฟล์ by-value.cpp
ใน 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); }
เนื่องจากฉันใช้คำสั่ง 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 ดังนั้นเราจึงสามารถเห็นความแตกต่างได้ แต่ถ้าเราต้องการให้ถูกต้องตามเงื่อนไข พารามิเตอร์ทั้งหมดควรได้รับการประกาศเช่นนั้น เนื่องจากไม่มีการแก้ไขใดๆ ในเนื้อหาของฟังก์ชัน (และพวกมัน ไม่ควร)
เนื่องจากมันไม่สำคัญสำหรับการประกาศฟังก์ชันซึ่งเป็นสิ่งที่ผู้เรียกเห็น เราสามารถสร้างส่วนหัว 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 const * const int *
หากคุณต้องการให้ตัวชี้เป็นค่าคงที่ กล่าวคือ ไม่สามารถเปลี่ยนตัวชี้ให้ชี้ไปที่อย่างอื่นได้ ให้เพิ่ม 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
ใช้สัญกรณ์แรก (const หลังประเภท) นอกจากนี้ โปรดทราบว่าข้อจำกัดเดียวที่ส่งออก และมีความสำคัญสำหรับผู้เรียกคือว่าฟังก์ชันจะแก้ไของค์ประกอบที่ชี้โดยตัวชี้หรือไม่ ความคงเส้นคงวาของตัวชี้เองนั้นไม่เกี่ยวข้องกับผู้โทร เนื่องจากตัวชี้เองจะถูกส่งผ่านเป็นสำเนาเสมอ ฟังก์ชันนี้สามารถสร้างสำเนาของตัวชี้เพื่อชี้ไปที่อื่นได้เท่านั้น ซึ่งไม่เกี่ยวข้องกับผู้โทร
ดังนั้น ไฟล์ส่วนหัวสามารถสร้างได้ดังนี้:
#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);
ผ่านพอยน์เตอร์ก็เหมือนส่งผ่าน ข้อแตกต่างประการหนึ่งคือเมื่อคุณผ่านการอ้างอิง ผู้เรียกจะถูกคาดหวังและถือว่าได้ส่งผ่านการอ้างอิงขององค์ประกอบที่ถูกต้อง ไม่ได้ชี้ไปที่ NULL หรือที่อยู่ที่ไม่ถูกต้องอื่นๆ ในขณะที่ตัวชี้อาจชี้ไปที่ NULL เป็นต้น สามารถใช้พอยน์เตอร์แทนการอ้างอิงเมื่อส่งค่า NULL ที่มีความหมายพิเศษ
เนื่องจากค่า C++11 สามารถส่งผ่านด้วยความหมายการย้ายได้ หัวข้อนี้จะไม่ได้รับการปฏิบัติในบทความนี้แต่สามารถศึกษาได้ในบทความอื่นๆ เช่น Argument Passing ใน C++
หัวข้อที่เกี่ยวข้องอื่นที่จะไม่กล่าวถึงในที่นี้คือวิธีการเรียกใช้ฟังก์ชันเหล่านั้นทั้งหมด หากส่วนหัวเหล่านั้นรวมมาจากไฟล์ต้นทางแต่ไม่ได้เรียก การคอมไพล์และการเชื่อมโยงจะสำเร็จ แต่ถ้าจะเรียกทุกฟังก์ชั่นก็จะมี error เพราะบางสายจะคลุมเครือ คอมไพเลอร์จะสามารถเลือกผลรวมมากกว่าหนึ่งเวอร์ชันสำหรับอาร์กิวเมนต์บางข้อ โดยเฉพาะอย่างยิ่งเมื่อเลือกว่าจะส่งผ่านการคัดลอกหรือโดยการอ้างอิง (หรือการอ้างอิง const) การวิเคราะห์นั้นอยู่นอกขอบเขตของบทความนี้
รวบรวมด้วยแฟล็กต่างๆ
ตอนนี้เรามาดูสถานการณ์ในชีวิตจริงที่เกี่ยวข้องกับหัวข้อนี้ซึ่งแมลงที่หายากสามารถปรากฏขึ้นได้
ไปที่ไดเร็กทอรี 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; };
คลาสนี้มีตัวนับสองตัว ซึ่งเริ่มต้นเป็นศูนย์และสามารถเพิ่มหรืออ่านได้ สำหรับบิลด์การดีบัก ซึ่งเป็นวิธีที่ฉันจะเรียกบิวด์โดยที่ไม่ได้กำหนดมาโคร NDEBUG
ฉันยังเพิ่มตัวนับตัวที่สาม ซึ่งจะเพิ่มขึ้นทุกครั้งที่มีการเพิ่มตัวนับอีกสองตัว นั่นจะเป็นตัวช่วยดีบักสำหรับคลาสนี้ คลาสไลบรารีของบริษัทอื่นจำนวนมากหรือแม้แต่ส่วนหัว C++ ในตัว (ขึ้นอยู่กับคอมไพเลอร์) ใช้เทคนิคเช่นนี้เพื่ออนุญาตให้มีการดีบักในระดับต่างๆ ซึ่งช่วยให้สร้างการดีบักเพื่อตรวจจับตัววนซ้ำที่อยู่นอกขอบเขต และสิ่งที่น่าสนใจอื่นๆ ที่ผู้สร้างไลบรารีสามารถคิดได้ ฉันจะเรียกรุ่นที่วางจำหน่ายว่า "บิลด์ที่มีการกำหนดมาโคร NDEBUG
"
สำหรับรุ่น builds ส่วนหัวที่คอมไพล์แล้วจะดูเหมือน (ฉันใช้ 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; };
มีตัวนับอีกตัวหนึ่งในการสร้างการดีบัก ตามที่ฉันอธิบายไว้ก่อนหน้านี้
ฉันยังสร้างไฟล์ตัวช่วยบางอย่าง
// 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; }
และ Makefile
ที่สามารถปรับแต่งค่าสถานะคอมไพเลอร์สำหรับ 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
ดังนั้น มาคอมไพล์มันในโหมดดีบั๊กโดยไม่ต้องกำหนด 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
กำหนดไว้ ซึ่งจะเป็นโหมดรีลีส และดูว่าเกิดอะไรขึ้น:
$ 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
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 Engineering:
- วิธีเรียนรู้ภาษา C และ C ++: รายการที่ดีที่สุด
- C # กับ C ++: Core คืออะไร?