Search

함수의 주소 출력

Created
2021/02/23
tag
C++
함수의 주소
멤버 함수의 주소
static 함수
virtual 함수

1. 들어가기 전에

일반적으로 프로그램을 컴파일하여 실행 파일을 만들면, 코드데이터 뭉치로 존재하게 된다. 코드데이터는 프로세스가 되어 상태를 가지게 되었을 때도 그 내용을 유지되게 되는데, 우리는 함수의 이름을 통해 함수의 주소를 얻을 수 있고 이를 통해 코드 뭉치에서 해당 함수를 찾을 수 있는 것이다.
요약하자면, 함수의 주소를 찾기 위해 함수의 이름을 이용할 수 있는 것이다.
GeeksforGeeks를 참고하자.
함수의 주소를 출력하기 위해서 printf%p 형식 지정자 혹은 cout을 이용하게 되는데, 실제로 함수의 이름을 통해서 함수의 주소를 출력해보려고 시도 했을 때 문제를 겪은 경우가 꽤 있을 것이다. 이에 대해서 차근 차근 풀어가보자.

2. 함수의 주소 출력

1) printf

#include <iostream> void test_func() { return ; } int main(void) { printf("%p\n", test_func); printf("%p\n", &test_func); return (0); }
C++
사진을 보면 알 수 있듯, printf를 이용하여 함수의 주소를 찍을 때, 함수의 이름 앞에 &를 붙여도 안 붙여도 같은 결과가 나왔다. 아무래도 &없이는 배열의 이름포인터암시적 형 변환하여 사용하고 배열의 이름&이 붙는 경우에는 배열 그 자체로 이용했던 것을 통해 짐작해보면, 함수 역시 비슷하게 작동하지 않을까 하는 생각이 든다. 함수의 이름&가 붙으면 함수 그 자체로, 함수의 이름만 이용하면 포인터로의 암시적 형 변환이 되는 식으로 말이다.
배열의 이름포인터암시적 형 변환하여 사용하는 것에 대해 더 알고 싶다면, 링크배열은 포인터인가?를 참고하자.

2) cout

출력 결과 및 문제점

#include <iostream> void test_func() { return ; } int main(void) { std::cout << test_func << "\n"; std::cout << &test_func << "\n"; return (0); }
C++
위와 같은 코드가 있을 때, cout의 경우는 printf와 달리 함수의 이름&를 붙인 것과 붙이지 않은 것의 실행 결과가 다르다. 정확하게 말하면, 함수의 이름&를 붙여야만 컴파일이 되었고 그 결과를 볼 수 있었다. 아래 그림처럼 &가 없이는 컴파일조차 되지 않았다.
이에 대한 이유를 찾기 이전에, &를 붙이고서 나온 결과에 대해서 먼저 살펴보자. 주어진 코드에서 &가 붙지 않은 cout 구문을 주석 처리 후 실행 했을 때는 아래와 같은 결과를 볼 수 있다.
사실, &를 붙이지 않고 오류가 발생한 이유와 &를 붙였을 때 올바른 결과를 얻지 못한 이유가 일맥상통하다.
어째서 &를 붙이지 않았을 때는 코드가 컴파일 조차 되지 않으며, &를 붙였을 때는 printf 출력 때와 달리 주소가 아닌 1이라는 값이 나오는 것일까? cout<< 연산자에 대한 Overloading된 함수 원형에서 힌트를 얻을 수 있다.
cout 객체를 이용한 출력 시에는 << 연산자를 이용하게 되는데 이 때 피연산자로 오는 다양한 타입의 인자들에 대해서 처리할 수 있어야하므로, << 연산자를 재정의한 함수들이 매우 많이 존재하는 것을 알 수 있다.
우리가 인자로 넘긴 test_func라는 함수의 이름은 주소 접근이 가능한 일종의 함수 포인터이다. 재정의된 많은 << 연산자에 대한 함수를 찾아보면 함수 포인터를 인자로 받는 함수는 존재하지 않는 것을 볼 수 있다. 따라서 컴파일러함수 포인터를 인자로 넘긴 구문을 처리하기 위해서 재정의된 함수들 중 가장 적합하게 처리할 수 있는 함수를 고르게 되는데 이 때 고르게 되는 것이 bool 타입 인자를 처리하는 ostream 함수이다.
인터넷에 존재하는 자료를 찾아보면, 컴파일러가 실행할 Overloading 함수를 고르는 기준은 우선 순위를 고려한 규칙에 의한 것을 알 수 있다.
따라서 cout에서 &가 붙지 않은 구문에서는 always evaluate to 'true'라고 나오는 것이고, &가 붙은 구문에서는 항상 1이 나오는 것이다.
주소 값이 존재한다고 하면, bool 타입의 Overloading 함수는 true를 반환하기 때문이다. 주어진 코드에서는 test_func가 정의 되어 있으므로 주소 값이 존재한다.
&가 붙지 않은 구문도 true이고 &가 붙은 구문도 1이라는 출력 결과가 나오는데, 어째서 &가 붙지 않은 구문만 오류를 뱉어내고 실행이 되지 않는 것일까? 이는 & 단항 연산자의 역할을 통해 알 수 있다.
&가 붙어있지 않은 함수의 이름은 항상 함수의 주소를 나타낸다고 했는데, 컴파일러는 이 함수의 주소를 받으면서 Overloading 함수를 이용하게 된다. 함수가 선언이 되어 있으면 함수는 항상 메모리를 점유 있는 것이고 이는 곧 주소 값이 존재한다는 것을 의미하기 때문에, 주소 값bool 타입 함수에 넣으면 항상 true가 나오는 것을 컴파일러는 알고 있다. 따라서 항상 true가 나오는 결과를 알고 있기 때문에 오류를 띄우면서 컴파일을 막게 된다.
반면에 &가 붙은 함수의 이름에 대해서는 함수 그 자체로써 작용한다고 했는데, & 연산자 특성 상 메모리를 점유하고 있는지 아닌지 주소 값을 확인하게 된다. 즉, 위의 과정처럼 항상 메모리를 점유하고 있다는 것을 알고 있다는 전제와 달리 컴파일러&의 피연산자가 메모리를 점유하고 있는지 아닌지에 대해 명확하게 알고 있지 않기 때문에 &를 통해 주소 값을 확인하겠다는 것을 전제로 하고 있다. 따라서 &가 붙은 것에 대해서는 컴파일을 막지 못하고, 연산 실행 결과 true에 따른 1이라는 결과 값을 내놓게 되는 것이다.

printf와 비교

cout에서는 주소 출력이 원활하게 되지 않았는데, printf에서는 주소 출력이 성공적으로 가능한 이유는 무엇일까? 또한 printf에서는 &가 붙든 붙지 않든 잘 처리 되던데 그 이유가 무엇일까?
이유는 처리 방식이 다르기 때문이라고 간단하게 볼 수 있다. cout의 경우는 많은 Overloading 함수들 중에서 컴파일러의 함수 선택 기준에 따라 실행을 하게 되고, 주소를 출력할 수 있는 함수가 선택되지 않았기 때문이다. 반면 printf의 경우는 Overloading 함수에서 골라 실행을 하는 방식이 아닌, 단일 함수 실행을 하고 가변 인자로써 처리를 하면서 주소 값을 처리할 수 있도록 되어 있기 때문이다. 그리고 printf가 가변 인자를 처리할 때는 이미 주소 값을 받은 상태로 처리하므로 함수의 이름에 붙은 &의 유무는 크게 중요하지 않은 것이다.
printf의 경우에는 컴파일러cout으로 출력할 때처럼 처리할 필요가 없다는 것을 의미한다. 따라서 &가 붙든 붙지 않든 결과적으로 주소 값을 받게 되고, 넘겨 받은 주소 값을 가변 인자를 통해 그대로 출력하게 된다.

올바른 주소 출력

C++에서는 4종류의 형 변환이 존재한다. 우리가 알고 있는 일반적인 형 변환이 static_cast이고, 이외에도 클래스의 다운 캐스팅 혹은 다중 상속에서 클래스 간의 안전한 형 변환과 관련된 dynamic_cast, const 특성을 제거하여 변환하는 const_cast, 임의의 포인터 간에 형 변환을 허용하는 reinterpret_cast 들이 있다.
cout을 이용하여 주소를 출력하기 위해선 인자를 bool타입으로 받는 Overloading 함수가 아닌 다른 함수를 이용해야 한다. 이 때 위 4가지 형 변환 중 하나를 이용하여 컴파일러bool 타입으로 처리하지 않도록 만들어줘야 한다. 어떤 타입으로 변환해야 할지 알아야 하므로 주소를 출력할 수 있는 가장 유력한 Overloading 함수를 먼저 찾아보면, basic_ostream& operator<<(const void* __p);와 같이 void *형의 인자를 받는 함수를 찾을 수 있다. 인자를 void *로 형 변환을 하면 해당 함수를 이용할 수 있으므로 이를 이용하여 출력해보자.
void *는 사용자가 원하는 타입으로 형 변환하여 사용할 수 있도록 둔 제네릭한 타입이기 때문에 어떠한 타입에 대해서도 형 변환이 가능하다. (메모리에서 몇 바이트로 끊어 읽을지 정해지지 않은 타입이기 때문에 제네릭한 것이며, 따라서함수 포인터로도 변환하여 사용할 수 있다.)
#include <iostream> void test_func() { return ; } int main(void) { std::cout << reinterpret_cast<void *>(test_func) << "\n"; std::cout << reinterpret_cast<void *>(&test_func) << "\n"; return (0); }
C++
위 코드를 실행해보면 아래 그림과 같이 주소 값이 성공적으로 출력되는 것을 확인할 수 있다.

3. 멤버 함수의 주소 출력

1) 출력 결과 및 문제점

일반적인 함수의 주소를 출력하기 위해선 함수의 이름을 이용했고, 함수의 이름함수 포인터로 형 변환되어 printf 혹은 cout의 인자로 사용되었다. 그렇다면 클래스의 멤버 함수의 주소 출력도 일반적인 함수의 주소를 출력하는 방법으로 나타낼 수 있을까? 아래 코드를 통해 printfcout&가 있는 경우와 없는 경우에 대해서 확인해보자.
#include <iostream> class Test { public: void test_func(void); }; void Test::test_func(void) { return ; } int main(void) { printf("%p\n", Test::test_func); std::cout << reinterpret_cast<void *>(Test::test_func) << "\n"; printf("%p\n", &Test::test_func); std::cout << reinterpret_cast<void *>(&Test::test_func) << "\n"; return (0); }
C++
위 코드를 컴파일 하면 아래 그림과 같이 컴파일이 되지 않는 것을 확인할 수 있다.
클래스의 멤버 함수static으로 선언된 함수를 제외하고선 객체를 통해서 멤버 함수를 호출해야 하는데, 첫 번째 시도의 printfcout에 대한 오류는 static으로 선언되지 않은 함수가 객체를 통해 호출되지 않았다고 인식하여 나타난 것으로 볼 수 있다. 즉, 클래스의 멤버 함수는 그 이름에 &가 붙지 않은 경우에 함수의 이름을 통해 주소를 찾는 행위보다 어떤 객체에 대한 멤버 함수의 호출인지 확인하는 행위를 먼저 수행하게 되는 것을 알 수 있다.
멤버 함수의 이름에 &를 붙여서 컴파일러에게 호출이 아니라 주소 값을 확인해라고 명시적으로 알려주는 경우에는 잘 작동해야 할 것 같은데, 그렇지 않은 것을 볼 수 있다. printf의 경우엔 가변 인자를 통해 주소 출력을 한다고 했고 그 인자는 함수 포인터가 이용된다고 했었다. 하지만 오류가 나는 것을 보아 void (Test::*)()이라는 멤버 함수를 나타내는 함수 포인터는 일반적인 함수를 나타내는 함수 포인터와 다르다는 것을 알 수 있다. 그렇다면 printf에서도 함수 포인터를 처리할 수 있도록 멤버 함수를 나타내는 함수 포인터void *로 변환해야 하는 것을 알 수 있는데, cout의 경우를 보면 void (Test::*)() 타입의 함수 포인터void * 형으로 변환하려 했을 때 오류가 난 것을 보아 멤버 함수를 나타내는 함수 포인터void *로의 형 변환이 불가능 하다는 것도 알 수 있다.
void *는 사용자가 원하는 타입으로 형 변환하여 사용할 수 있도록 둔 제네릭한 타입이라고 하여 함수 포인터로 변환이 가능하다고 했었는데, 멤버 함수를 나타내는 함수 포인터는 변환이 불가능한 것을 보아 자체적으로 막아둔 것이라고 추측할 수 있다. 실제로 여기를 확인해보면, gcc에서는 멤버 함수 혹은 멤버 함수를 참조하는 함수 포인터에 대한 void *로의 형 변환을 허용해주지 않는 것을 알 수 있다. (함수 포인터의 타입을 통해 함수의 네임 스페이스가 굉장히 중요하다는 것도 유추할 수 있다.)

2) 올바른 주소 출력

멤버 함수의 주소를 출력하는 것은 불가능한 것일까? 일반적인 방법으로는 불가능하고 아래 코드처럼 함수 포인터네임 스페이스void *&를 잘 이용하면 가능하다. (void *&void *형 참조자이다.)
#include <iostream> class Test { public: void test_func(void); }; void Test::test_func(void) { return ; } int main(void) { void (Test::*fptr)(void) = &Test::test_func; printf("%p\n", reinterpret_cast<void *&>(fptr)); std::cout << reinterpret_cast<void *&>(fptr) << "\n"; return (0); }
C++
위처럼 멤버 함수네임 스페이스가 표기된 함수 포인터 lvalue를 이용하지 않으면, 아래와 같이 rvaluevoid*& 로 변환하려 했다는 오류를 볼 수 있다. void (Test::fptr)(void) 와 같은 긴 타입 선언은 auto *fptr로 대체할 수 있다. (reinterpret_cast를 이용하여 참조자 타입으로 형 변환을 진행할 때 lvalue만 받고, rvalue는 사용할 수 없도록 막혀 있다.) 이유에 대해선 공부해봐야 알 듯하다...
일반적으로 리터럴과 같은 rvalue참조자로 이용하려면 상수 참조자로 받으면 가능했지만reinterpret_cast의 경우에는 rvalue에서 상수 참조자로의 변환도 안 되는데, 이 이유도 공부해봐야 알 듯하다... 아마 위 이유를 찾으면 그 이유와 동일할 듯 하다...
오로지 void *&를 이용해서만 멤버 함수의 주소를 볼 수 있다는 것이... 약간은 꼼수처럼 느껴진다...
이 기법을 이용하여 static 멤버 함수virtual 멤버 함수 주소를 모두 출력해보자.

3) static 멤버 함수의 주소

#include <iostream> class Test { public: static void test_func(void); }; void Test::test_func(void) { return ; } int main(void) { auto *fptr = &Test::test_func; printf("%p\n", reinterpret_cast<void *&>(fptr)); std::cout << reinterpret_cast<void *&>(fptr) << "\n"; return (0); }
C++
static 멤버 함수 역시 void *&를 이용하여 멤버 함수의 주소를 출력하는 것과 마찬가지로 잘 동작하는 것을 확인할 수 있다. 다만 static 멤버 함수의 경우에는 다른 멤버 함수와 달리 클래스를 객체화 (Instantiate) 하지 않아도 함수 호출이 가능한 형태이고 (클래스 간 공유 함수로 작동하기 때문에), 따라서 static 멤버 함수에 한하여 아래 코드와 같이 reinterpret_cast를 이용한 void *로의 형 변환이 가능하다. 즉, static 멤버 함수는 일반적인 함수의 주소를 출력하는 방법으로도 주소를 확인할 수 있다.
#include <iostream> class Test { public: static void test_func(void); }; void Test::test_func(void) { return ; } int main(void) { printf("%p\n", reinterpret_cast<void *>(&Test::test_func)); std::cout << reinterpret_cast<void *>(&Test::test_func) << "\n"; return (0); }
C++

4) virtual 멤버 함수의 주소

virtual 멤버 함수의 경우는 static 멤버 함수와 달리 auto 키워드를 이용한 형 추론 (Type Inference)가 불가능 하다. 이는 컴파일러가 컴파일 타임에 형 추론이 불가능하다는 것을 의미하는데 클래스 내 virtual 함수의 특성 상, 객체의 Parent에 해당하는 멤버 함수를 바로 호출하는 것이 아니라 vTable을 타고가서 객체의 타입에 맞춰 호출해야 하는 함수의 주소를 취득하여 함수를 이용하게 되기 때문이다.
vTable에 대한 참고 자료는 Reference의 최하단 2개의 링크를 참고하자.
따라서 static 멤버 함수의 주소 출력과 비슷한 코드로 결과를 얻어보면 재밌는 결과를 얻을 수 있다.
#include <iostream> class Test1 { public: virtual void test1_func(void); }; void Test1::test1_func(void) { return ; } class Test2 { public: virtual void test2_func(void); }; void Test2::test2_func(void) { return ; } int main(void) { void (Test1::*fptr1)(void) = &Test1::test1_func; printf("%p\n", reinterpret_cast<void *&>(fptr1)); std::cout << reinterpret_cast<void *&>(fptr1) << "\n"; void (Test2::*fptr2)(void) = &Test2::test2_func; printf("%p\n", reinterpret_cast<void *&>(fptr2)); std::cout << reinterpret_cast<void *&>(fptr2) << "\n"; return (0); }
C++
함수의 주소가 출력되는 것이 아니라 메모리의 최상단 주소가 찍히는 것을 볼 수 있다. 위 방법을 이용하면 함수의 주소를 출력하는 것이 아니라 무언가 다른 것을 출력하는 것처럼 보이는데, 이것이 vTable임을 유추할 수 있다. 환경마다 다를 순 있지만 적어도 내 환경에서는 vTableCode Segment 최상단에 위치한다는 흥미로운 결과까지도 연결 지을 수 있다.
virtual 멤버 함수의 경우엔 static 멤버 함수처럼 클래스 내의 공유 함수가 아니기 때문에, 멤버 함수void *로의 형 변환이 불가능하다.
차후에는 vTable에 기록된 함수의 주소들은 어떻게 확인할 수 있는지도 알아봐야겠다...

4. Reference