C++ 작동 방식: 컴파일 이해
게시 됨: 2022-03-11Bjarne Stroustrup의 The C++ Programming Language 에는 "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_64g++ 버전 사용:
$ g++ --version g++ (GCC) 4.8.5 20150623 (Red Hat 4.8.5-11)제공된 소스 파일은 다른 운영 체제로 이식할 수 있어야 하지만 자동화된 빌드 프로세스를 위해 함께 제공되는 Makefile은 유닉스 계열 시스템에만 이식 가능해야 합니다.
빌드 파이프라인: 사전 처리, 컴파일 및 연결
각 C++ 소스 파일은 개체 파일로 컴파일해야 합니다. 여러 소스 파일을 컴파일하여 생성된 개체 파일은 실행 파일, 공유 라이브러리 또는 정적 라이브러리(이 중 마지막 개체 파일의 아카이브)에 연결됩니다. C++ 소스 파일에는 일반적으로 .cpp, .cxx 또는 .cc 확장자가 있습니다.
C++ 소스 파일은 #include 지시문을 사용하여 헤더 파일이라고 하는 다른 파일을 포함할 수 있습니다. 헤더 파일에는 .h, .hpp 또는 .hxx와 같은 확장자가 있거나 C++ 표준 라이브러리 및 Qt와 같은 다른 라이브러리의 헤더 파일과 같이 확장자가 전혀 없습니다. 확장자는 C++ 전처리기의 경우 중요하지 않습니다. 이 전처리기는 문자 그대로 #include 지시문이 포함된 행을 포함된 파일의 전체 내용으로 대체합니다.
컴파일러가 소스 파일에 대해 수행하는 첫 번째 단계는 소스 파일에서 전처리기를 실행하는 것입니다. 소스 파일만 컴파일러에 전달됩니다(전처리 및 컴파일). 헤더 파일은 컴파일러에 전달되지 않습니다. 대신 소스 파일에서 포함됩니다.
각 헤더 파일은 모든 소스 파일의 전처리 단계에서 여러 번 열릴 수 있으며, 이는 얼마나 많은 소스 파일이 포함되어 있는지 또는 소스 파일에서 포함된 다른 헤더 파일이 얼마나 많은 다른 헤더 파일도 포함하는지에 따라 다릅니다(많은 수준의 간접 참조가 있을 수 있음) . 반면에 소스 파일은 컴파일러(및 전처리기)에 전달될 때 한 번만 열립니다.
각 C++ 소스 파일에 대해 전처리기는 #include 지시문을 찾을 때 내용을 삽입하여 번역 단위를 빌드함과 동시에 조건부 컴파일을 찾으면 소스 파일과 헤더에서 코드를 제거합니다. 지시문이 false 로 평가되는 블록 또한 매크로 교체와 같은 다른 작업도 수행합니다.
전처리기가 그 (때로는 거대한) 번역 단위 생성을 마치면 컴파일러는 컴파일 단계를 시작하고 목적 파일을 생성합니다.
해당 번역 단위(전처리된 소스 코드)를 얻으려면 전처리된 소스 파일의 원하는 이름을 지정하는 -o 옵션과 함께 -E 옵션을 g++ 컴파일러에 전달할 수 있습니다.
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 언어와 유사합니다. 컴파일에 대한 C 규칙을 따르며 헤더 파일을 포함하고 객체 코드를 생성하는 방식은 거의 동일합니다.
소스 파일이 심볼을 가져오고 내보내는 방법
이제 cpp-article/symbols/c-vs-cpp-names 디렉토리에 있는 파일을 살펴보겠습니다.
sum.c라는 이름의 간단한 C(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라는 C++ 소스 파일에서 볼 수 있습니다.
#include <iostream> // std::cout, std::endl #include "sum.h" // sumI, sumF void printSum(int a, int b) { std::cout << a << " + " << b << " = " << sumI(a, b) << std::endl; } void printSum(float a, float b) { std::cout << a << " + " << b << " = " << sumF(a, b) << std::endl; } extern "C" void printSumInt(int a, int b) { printSum(a, b); } extern "C" void printSumFloat(float a, float b) { printSum(a, b); } 매개변수 유형만 다른 동일한 이름( printSum )을 가진 두 개의 함수가 있습니다: int 또는 float . 함수 오버로딩은 C에 없는 C++ 기능입니다. 이 기능을 구현하고 이러한 기능을 구별하기 위해 C++는 내보낸 기호 이름에서 볼 수 있듯이 함수 이름을 변경합니다(nm의 출력에서 관련 있는 것만 선택하겠습니다) :
$ g++ -c print.cpp $ nm print.o 0000000000000132 T printSumFloat 0000000000000113 T printSumInt U sumF U sumI 0000000000000074 T _Z8printSumff 0000000000000000 T _Z8printSumii U _ZSt4cout 이러한 함수는 (내 시스템에서) float 버전의 경우 _Z8printSumff 로, int 버전의 경우 _Z8printSumii 로 내보내집니다. C++의 모든 함수 이름은 extern "C" 로 선언되지 않는 한 맹글링됩니다. print.cpp 에서 C 연결로 선언된 두 가지 함수가 있습니다: printSumInt 및 printSumFloat .
따라서 오버로드할 수 없거나 맹글링되지 않았기 때문에 내보낸 이름이 동일합니다. Int 또는 Float를 이름 끝에 접미사로 붙여 서로 구별해야 했습니다.
그것들은 맹글링되지 않았기 때문에 우리가 곧 보게 될 것처럼 C 코드에서 호출될 수 있습니다.
C++ 소스 코드에서 볼 수 있는 것처럼 망가진 이름을 보려면 nm 명령에서 -C (demangle) 옵션을 사용할 수 있습니다. 다시 말하지만, 출력의 동일한 관련 부분만 복사하겠습니다.
$ 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 는 sum.h 에 C 연결이 있는 것으로 선언된 C 함수인 sumI 및 sumF 를 호출합니다. 이것은 정의되지 않은(U) 기호 sumF , sumI 및 std::cout 를 알려주는 위의 print.o 의 nm 출력에서 볼 수 있습니다. 이러한 정의되지 않은 기호는 링크 단계에서 이 오브젝트 파일 출력과 함께 링크될 오브젝트 파일(또는 라이브러리) 중 하나에 제공되어야 합니다.
지금까지 우리는 소스 코드를 객체 코드로 컴파일했고 아직 링크하지 않았습니다. 가져온 심볼에 대한 정의가 포함된 개체 파일을 이 개체 파일과 함께 연결하지 않으면 링커는 "기호 누락" 오류와 함께 중지됩니다.
또한 print.cpp 는 C++ 컴파일러(g++)로 컴파일된 C++ 소스 파일이므로 그 안의 모든 코드는 C++ 코드로 컴파일됩니다. printSumInt 및 printSumFloat 와 같은 C 연결이 있는 함수도 C++ 기능을 사용할 수 있는 C++ 함수입니다. 기호의 이름만 C와 호환되지만 코드는 C++이며 두 함수 모두 오버로드된 함수( printSum )를 호출하고 있다는 사실에서 알 수 있습니다. 이는 printSumInt 또는 printSumFloat 가 C로 컴파일된 경우 발생할 수 없습니다.
이제 C 또는 C++ 소스 파일에서 모두 포함될 수 있는 헤더 파일인 print.hpp 를 살펴보겠습니다. 이를 통해 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" #endifC 소스 파일에서 포함하는 경우 다음을 확인하기만 하면 됩니다.
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++ 함수의 경우)이 다를 수 있는 다른 호출 규칙(매개변수가 전달되고 반환 값이 반환되는 방식)이 있기 때문에 호출이 예상대로 작동할지조차 모르겠습니다. 멤버 함수이고 this 포인터를 매개변수로 받습니다.
컴파일러는 잠재적으로 일반 C++ 함수에 대해 하나의 호출 규칙을 사용하고 extern "C" 연결이 있는 것으로 선언된 경우 다른 호출 규칙을 사용할 수 있습니다. 따라서 한 함수는 C 호출 규칙을 사용하지만 실제로는 C++를 사용한다고 말함으로써 컴파일러를 속이는 것은 컴파일 도구 체인에서 각각에 사용되는 규칙이 다른 경우 예기치 않은 결과를 제공할 수 있기 때문입니다.
C와 C++ 코드를 혼합하는 표준 방법이 있으며 C에서 C++ 오버로드된 함수를 호출하는 표준 방법은 printSumInt 및 printSum 로 printSumFloat 을 래핑하여 C 연결을 사용하여 함수로 래핑하는 것입니다.
C++ 소스 파일에서 print.hpp 를 포함하면 __cplusplus 전처리기 매크로가 정의되고 파일은 다음과 같이 표시됩니다.
void printSum(int a, int b); void printSum(float a, float b); extern "C" { void printSumInt(int a, int b); void printSumFloat(float a, float b); } // end extern "C" 이렇게 하면 C++ 코드에서 오버로드된 함수인 printSum 또는 해당 래퍼 printSumInt 및 printSumFloat 를 호출할 수 있습니다.
이제 프로그램의 진입점인 main 함수를 포함하는 C 소스 파일을 생성해 보겠습니다. 이 C 주 함수는 printSumInt 및 printSumFloat 를 호출합니다. 즉, C 연결을 사용하여 두 C++ 함수를 모두 호출합니다. 그것들은 C++ 맹글링된 이름만 가지고 있지 않은 C++ 함수(함수 본체가 C++ 코드를 실행함)라는 것을 기억하십시오. 파일 이름은 c-main.c 입니다.
#include "print.hpp" int main(int argc, char* argv[]) { printSumInt(1, 2); printSumFloat(1.5f, 2.5f); return 0; }컴파일하여 개체 파일을 생성합니다.
$ gcc -c c-main.c가져오기/내보낸 기호를 확인합니다.
$ nm c-main.o 0000000000000000 T main U printSumFloat U printSumInt 예상대로 main을 printSumFloat 및 printSumInt 를 가져옵니다.
이를 모두 함께 실행 파일로 연결하려면 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 이제 cpp-main.cpp 라는 C++ 기본 파일을 사용해 보겠습니다.
#include "print.hpp" int main(int argc, char* argv[]) { printSum(1, 2); printSum(1.5f, 2.5f); printSumInt(3, 4); printSumFloat(3.5f, 4.5f); return 0; } cpp-main.o 개체 파일의 가져오기/내보낸 기호를 컴파일하고 확인합니다.
$ g++ -c cpp-main.cpp $ nm -C cpp-main.o 0000000000000000 T main U printSumFloat U printSumInt U printSum(float, float) U printSum(int, int) main 을 내보내고 C 연결 printSumFloat 및 printSumInt , 그리고 printSum 의 두 맹글링된 버전을 가져옵니다.
메인 심볼이 C++ 소스 파일이고 extern "C" 로 정의되어 있지 않기 때문에 이 C++ 소스에서 main(int, char**) 과 같은 맹글링된 심볼로 내보내지지 않는 이유가 궁금할 것입니다. 글쎄요, main 은 특별한 구현 정의 함수이고 내 구현은 C 또는 C++ 소스 파일에 정의되어 있든 상관없이 C 연결을 사용하기로 선택한 것 같습니다.
프로그램을 연결하고 실행하면 예상한 결과가 나타납니다.
$ g++ -o cpp-app sum.o print.o cpp-main.o $ ./cpp-app 1 + 2 = 3 1.5 + 2.5 = 4 3 + 4 = 7 3.5 + 4.5 = 8헤더 가드의 작동 방식
지금까지 동일한 소스 파일에서 직접 또는 간접적으로 헤더를 두 번 포함하지 않도록 주의했습니다. 그러나 하나의 헤더에 다른 헤더가 포함될 수 있으므로 동일한 헤더가 간접적으로 여러 번 포함될 수 있습니다. 그리고 헤더 내용이 포함된 위치에 바로 삽입되기 때문에 중복 선언으로 끝나기 쉽습니다.
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 { ^간결함을 위해 대부분이 짧은 예이므로 필요하지 않은 경우 이 기사에서 보호된 헤더를 사용하지 않겠습니다. 그러나 항상 헤더 파일을 보호하십시오. 어디에서나 포함되지 않는 소스 파일이 아닙니다. 헤더 파일만 있으면 됩니다.
매개변수의 값과 일관성으로 전달
cpp-article/symbols/pass-by 에서 by-value.cpp 파일을 확인합니다.
#include <vector> #include <numeric> #include <iostream> // std::vector, std::accumulate, std::cout, std::endl using namespace std; int sum(int a, const int b) { cout << "sum(int, const int)" << endl; const int c = a + b; ++a; // Possible, not const // ++b; // Not possible, this would result in a compilation error return c; } float sum(const float a, float b) { cout << "sum(const float, float)" << endl; return a + b; } int sum(vector<int> v) { cout << "sum(vector<int>)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const vector<float> v) { cout << "sum(const vector<float>)" << endl; return accumulate(v.begin(), v.end(), 0.0f); } using namespace std 지시문을 사용하기 때문에 나머지 번역 단위(제 경우에는 소스 파일의 나머지 부분)에 있는 std 네임스페이스 내의 기호(함수 또는 클래스) 이름을 한정할 필요가 없습니다. 이것이 헤더 파일이라면 헤더 파일이 여러 소스 파일에서 포함되어야 하기 때문에 이 지시문을 삽입하지 말았어야 했습니다. 이 지시문은 내 헤더를 포함하는 지점에서 전체 std 네임스페이스를 각 소스 파일의 전역 범위로 가져옵니다.

해당 파일에서 내 뒤에 포함된 헤더에도 해당 기호가 범위에 있습니다. 이러한 일이 발생할 것으로 예상하지 않았기 때문에 이름 충돌이 발생할 수 있습니다. 따라서 헤더에 이 지시문을 사용하지 마십시오. 원하는 경우 모든 헤더를 포함시킨 후에만 소스 파일에서 사용하십시오.
일부 매개변수가 어떻게 const인지 확인하십시오. 이것은 우리가 시도하는 경우 함수의 본문에서 변경할 수 없음을 의미합니다. 컴파일 오류가 발생합니다. 또한 이 소스 파일의 모든 매개변수는 참조(&) 또는 포인터(*)가 아닌 값으로 전달됩니다. 이것은 호출자가 복사본을 만들어 함수에 전달할 것임을 의미합니다. 따라서 호출자가 const인지 아닌지는 중요하지 않습니다. 함수 본문에서 수정하면 호출자가 함수에 전달한 원래 값이 아니라 복사본만 수정하기 때문입니다.
값(복사)으로 전달되는 매개변수의 상수는 호출자에게 중요하지 않기 때문에 객체 코드를 컴파일하고 검사한 후(관련 출력만) 볼 수 있으므로 함수 서명에서 맹글링되지 않습니다.
$ g++ -c by-value.cpp $ nm -C by-value.o 000000000000001e T sum(float, float) 0000000000000000 T sum(int, int) 0000000000000087 T sum(std::vector<float, std::allocator<float> >) 0000000000000048 T sum(std::vector<int, std::allocator<int> >)서명은 복사된 매개변수가 함수 본문에서 const인지 여부를 표현하지 않습니다. 그것은 중요하지 않습니다. 함수 정의에만 해당 값이 변경되는지 여부를 함수 본문의 독자에게 한 눈에 보여주는 것이 중요했습니다. 예제에서 매개변수의 절반만 const로 선언되어 대비를 볼 수 있지만 const-correct를 원하면 함수 본문에서 수정된 것이 없기 때문에 모두 선언해야 합니다. 해서는 안 됨).
호출자가 보는 함수 선언은 중요하지 않으므로 다음과 같이 by-value.hpp 헤더를 만들 수 있습니다.
#include <vector> int sum(int a, int b); float sum(float a, float b); int sum(std::vector<int> v); int sum(std::vector<float> v);여기에 const 한정자를 추가하는 것은 허용되지만(정의에서 const가 아닌 const 변수로 한정할 수도 있으며 작동할 것입니다), 이것은 필요하지 않으며 선언을 불필요하게 장황하게 만들 뿐입니다.
참조로 전달
by-reference.cpp 살펴보겠습니다.
#include <vector> #include <iostream> #include <numeric> using namespace std; int sum(const int& a, int& b) { cout << "sum(const int&, int&)" << endl; const int c = a + b; ++b; // Will modify caller variable // ++a; // Not allowed, but would also modify caller variable return c; } float sum(float& a, const float& b) { cout << "sum(float&, const float&)" << endl; return a + b; } int sum(const std::vector<int>& v) { cout << "sum(const std::vector<int>&)" << endl; return accumulate(v.begin(), v.end(), 0); } float sum(const std::vector<float>& v) { cout << "sum(const std::vector<float>&)" << endl; return accumulate(v.begin(), v.end(), 0.0f); }참조로 전달할 때 Constness는 호출자에게 중요합니다. 호출자에게 인수가 호출 수신자에 의해 수정되는지 여부를 알려주기 때문입니다. 따라서 기호는 상수와 함께 내보내집니다.
$ g++ -c by-reference.cpp $ nm -C by-reference.o 0000000000000051 T sum(float&, float const&) 0000000000000000 T sum(int const&, int&) 00000000000000fe T sum(std::vector<float, std::allocator<float> > const&) 00000000000000a3 T sum(std::vector<int, std::allocator<int> > const&)이는 호출자가 사용할 헤더에도 반영되어야 합니다.
#include <vector> int sum(const int&, int&); float sum(float&, const float&); int sum(const std::vector<int>&); float sum(const std::vector<float>&);지금까지 했던 것처럼 선언(헤더에)에 변수 이름을 쓰지 않았습니다. 이것은 이 예와 이전 예에서도 합법입니다. 호출자는 변수 이름을 지정하는 방법을 알 필요가 없으므로 선언에 변수 이름이 필요하지 않습니다. 그러나 매개변수 이름은 일반적으로 선언에서 바람직하므로 사용자는 각 매개변수가 무엇을 의미하는지, 따라서 호출에서 무엇을 보낼지 한눈에 알 수 있습니다.
놀랍게도, 변수 이름은 함수 정의에 필요하지 않습니다. 함수에서 매개변수를 실제로 사용하는 경우에만 필요합니다. 그러나 사용하지 않는 경우 매개변수를 유형과 함께 이름 없이 그대로 둘 수 있습니다. 함수가 절대 사용하지 않을 매개변수를 선언하는 이유는 무엇입니까? 때때로 함수(또는 메소드)는 관찰자에게 전달되는 특정 매개변수를 정의하는 콜백 인터페이스와 같은 인터페이스의 일부일 뿐입니다. 관찰자는 인터페이스가 지정하는 모든 매개변수를 사용하여 콜백을 생성해야 합니다. 이는 호출자가 모두 전송하기 때문입니다. 그러나 관찰자는 이들 모두에 관심이 없을 수 있으므로 "사용하지 않는 매개변수"에 대한 컴파일러 경고를 수신하는 대신 함수 정의는 이름 없이 그대로 둘 수 있습니다.
포인터로 전달
// by-pointer.cpp: #include <iostream> #include <vector> #include <numeric> using namespace std; int sum(int const * a, int const * const b) { cout << "sum(int const *, int const * const)" << endl; const int c = *a+ *b; // *a = 4; // Can't change. The value pointed to is const. // *b = 4; // Can't change. The value pointed to is const. a = b; // I can make a point to another const int // b = a; // Can't change where b points because the pointer itself is const. return c; } float sum(float * const a, float * b) { cout << "sum(int const * const, float const *)" << endl; return *a + *b; } int sum(const std::vector<int>* v) { cout << "sum(std::vector<int> const *)" << endl; // v->clear(); // I can't modify the const object pointed by v const int c = accumulate(v->begin(), v->end(), 0); v = NULL; // I can make v point to somewhere else return c; } float sum(const std::vector<float> * const v) { cout << "sum(std::vector<float> const * const)" << endl; // v->clear(); // I can't modify the const object pointed by v // v = NULL; // I can't modify where the pointer points to return accumulate(v->begin(), v->end(), 0.0f); }const 요소(예제에서는 int)에 대한 포인터를 선언하려면 다음 중 하나로 유형을 선언할 수 있습니다.
int const * const int *포인터 자체도 const가 되도록 하려면, 즉 포인터가 다른 것을 가리키도록 변경할 수 없도록 하려면 별 뒤에 const를 추가합니다.
int const * const const int * const포인터 자체가 const이길 원하지만 포인터가 가리키는 요소는 원하지 않는 경우:
int * const함수 서명을 개체 파일의 demangle 검사와 비교하십시오.
$ 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 값은 이동 의미 체계와 함께 전달할 수도 있습니다. 이 주제는 이 기사에서 다루지 않지만 C++의 인수 전달과 같은 다른 기사에서 연구할 수 있습니다.
여기서 다루지 않을 또 다른 관련 주제는 이러한 모든 함수를 호출하는 방법입니다. 이러한 모든 헤더가 소스 파일에서 포함되지만 호출되지 않으면 컴파일 및 연결이 성공합니다. 그러나 모든 함수를 호출하려는 경우 일부 호출이 모호하기 때문에 약간의 오류가 발생합니다. 컴파일러는 특히 복사 또는 참조(또는 const 참조)로 전달할지 여부를 선택할 때 특정 인수에 대해 둘 이상의 sum 버전을 선택할 수 있습니다. 그 분석은 이 기사의 범위를 벗어납니다.
다른 플래그로 컴파일하기
이제 이 주제와 관련하여 찾기 힘든 버그가 나타날 수 있는 실제 상황을 살펴보겠습니다.
cpp-article/diff-flags 디렉토리로 이동하여 Counters.hpp 를 보십시오.
class Counters { public: Counters() : #ifndef NDEBUG // Enabled in debug builds m_debugAllCounters(0), #endif m_counter1(0), m_counter2(0) { } #ifndef NDEBUG // Enabled in debug build #endif void inc1() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter1; } void inc2() { #ifndef NDEBUG // Enabled in debug build ++m_debugAllCounters; #endif ++m_counter2; } #ifndef NDEBUG // Enabled in debug build int getDebugAllCounters() { return m_debugAllCounters; } #endif int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: #ifndef NDEBUG // Enabled in debug builds int m_debugAllCounters; #endif int m_counter1; int m_counter2; }; 이 클래스에는 0으로 시작하고 증가하거나 읽을 수 있는 두 개의 카운터가 있습니다. NDEBUG 매크로가 정의되지 않은 빌드를 호출하는 디버그 빌드의 경우, 다른 두 카운터가 증가할 때마다 증가되는 세 번째 카운터도 추가합니다. 이것은 이 클래스에 대한 일종의 디버그 도우미가 될 것입니다. 많은 타사 라이브러리 클래스 또는 내장 C++ 헤더(컴파일러에 따라 다름)는 이와 같은 트릭을 사용하여 다양한 수준의 디버깅을 허용합니다. 이를 통해 디버그 빌드는 범위를 벗어나는 반복자와 라이브러리 제작자가 생각할 수 있는 기타 흥미로운 것들을 감지할 수 있습니다. 릴리스 빌드를 " NDEBUG 매크로가 정의된 빌드"라고 부를 것입니다.
릴리스 빌드의 경우 미리 컴파일된 헤더는 다음과 같습니다( grep 을 사용하여 빈 줄 제거).
$ g++ -E -DNDEBUG Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_counter1(0), m_counter2(0) { } void inc1() { ++m_counter1; } void inc2() { ++m_counter2; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_counter1; int m_counter2; };디버그 빌드의 경우 다음과 같습니다.
$ g++ -E Counters.hpp | grep -v -e '^$' # 1 "Counters.hpp" # 1 "<built-in>" # 1 "<command-line>" # 1 "/usr/include/stdc-predef.h" 1 3 4 # 1 "<command-line>" 2 # 1 "Counters.hpp" class Counters { public: Counters() : m_debugAllCounters(0), m_counter1(0), m_counter2(0) { } void inc1() { ++m_debugAllCounters; ++m_counter1; } void inc2() { ++m_debugAllCounters; ++m_counter2; } int getDebugAllCounters() { return m_debugAllCounters; } int get1() const { return m_counter1; } int get2() const { return m_counter2; } private: int m_debugAllCounters; int m_counter1; int m_counter2; };앞에서 설명한 것처럼 디버그 빌드에는 카운터가 하나 더 있습니다.
또한 몇 가지 도우미 파일을 만들었습니다.
// increment1.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment1(Counters&); // increment1.cpp: #include "Counters.hpp" void increment1(Counters& c) { c.inc1(); } // increment2.hpp: // Forward declaration so I don't have to include the entire header here class Counters; int increment2(Counters&); // increment2.cpp: #include "Counters.hpp" void increment2(Counters& c) { c.inc2(); } // main.cpp: #include <iostream> #include "Counters.hpp" #include "increment1.hpp" #include "increment2.hpp" using namespace std; int main(int argc, char* argv[]) { Counters c; increment1(c); // 3 times increment1(c); increment1(c); increment2(c); // 4 times increment2(c); increment2(c); increment2(c); cout << "c.get1(): " << c.get1() << endl; // Should be 3 cout << "c.get2(): " << c.get2() << endl; // Should be 4 #ifndef NDEBUG // For debug builds cout << "c.getDebugAllCounters(): " << c.getDebugAllCounters() << endl; // Should be 3 + 4 = 7 #endif return 0; } 그리고 increment2.cpp 에 대해서만 컴파일러 플래그를 사용자 정의할 수 있는 Makefile :
all: main.o increment1.o increment2.o g++ -o diff-flags main.o increment1.o increment2.o main.o: main.cpp increment1.hpp increment2.hpp Counters.hpp g++ -c -O2 main.cpp increment1.o: increment1.cpp Counters.hpp g++ -c $(CFLAGS) -O2 increment1.cpp increment2.o: increment2.cpp Counters.hpp g++ -c -O2 increment2.cpp clean: rm -f *.o diff-flags 따라서 NDEBUG 를 정의하지 않고 디버그 모드에서 모두 컴파일해 보겠습니다.
$ CFLAGS='' make g++ -c -O2 main.cpp g++ -c -O2 increment1.cpp g++ -c -O2 increment2.cpp g++ -o diff-flags main.o increment1.o increment2.o이제 실행:
$ ./diff-flags c.get1(): 3 c.get2(): 4 c.getDebugAllCounters(): 7 출력은 예상대로입니다. 이제 릴리스 모드가 될 NDEBUG 가 정의된 파일 중 하나만 컴파일하고 어떤 일이 발생하는지 봅시다.
$ 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 함수는 두 개의 int 멤버 필드만 있는 Counters 클래스의 릴리스 버전을 보았습니다. So, it incremented the first field, thinking that it was m_counter1 , and didn't increment anything else since it knows nothing about the m_debugAllCounters field. I say that increment1 incremented the counter because the inc1 method in Counter is inline, so it was inlined in increment1 function body, not called from it. The compiler probably decided to inline it because the -O2 optimization level flag was used.
So, m_counter1 was never incremented and m_debugAllCounters was incremented instead of it by mistake in increment1 . That's why we see 0 for m_counter1 but we still see 7 for m_debugAllCounters .
Working in a project where we had tons of source files, grouped in many static libraries, it happened that some of those libraries were compiled without debugging options for std::vector , and others were compiled with those options.
Probably at some point, all libraries were using the same flags, but as time passed, new libraries were added without taking those flags into consideration (they weren't default flags, they had been added by hand). We used an IDE to compile, so to see the flags for each library, you had to dig into tabs and windows, having different (and multiple) flags for different compilation modes (release, debug, profile…), so it was even harder to note that the flags weren't consistent.
This caused that in the rare occasions when an object file, compiled with one set of flags, passed a std::vector to an object file compiled with a different set of flags, which did certain operations on that vector, the application crashed. Imagine that it wasn't easy to debug since the crash was reported to happen in the release version, and it didn't happen in the debug version (at least not in the same situations that were reported).
The debugger also did crazy things because it was debugging very optimized code. The crashes were happening in correct and trivial code.
The Compiler Does a Lot More Than You May Think
In this article, you have learned about some of the basic language constructs of C++ and how the compiler works with them, starting from the processing stage to the linking stage. Knowing how it works can help you look at the whole process differently and give you more insight into these processes that we take for granted in C++ development.
From a three-step compilation process to mangling of function names and producing different function signatures in different situations, the compiler does a lot of work to offer the power of C++ as a compiled programming language.
I hope you will find the knowledge from this article useful in your C++ projects.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- C 및 C++ 언어를 배우는 방법: 궁극적인 목록
- C# 대 C++: 핵심은 무엇입니까?
