[C++] 함수를 전달하기 위한 함수 포인터( function pointer )

반응형

함수 포인터( function pointer )

포인터( pointer )란 객체의 메모리 주소를 저장하는 변수입니다.

이러한 포인터 중에, 함수의 메모리 주소를 저장하는 포인터를 함수 포인터라고 합니다.

int var{ 5 };
int* ptr{ &var };	// int 타입 변수에 대한 포인터

위와 같이, 포인터를 선언하려면 포인터가 가리키는 객체의 타입과 * 기호를 사용합니다.

그리고, 함수 포인터를 선언하려면, 함수 포인터가 가리키는 함수의 타입과 *기호가 필요합니다..

그런데, 여기서 함수의 타입( function type )은 무엇일까요?

 

함수를 정의하는데 반드시 필요한 것은, 함수의 이름과, 매개변수 타입 리스트, 그리고 반환 타입입니다.

( 매개변수의 이름은 경우에 따라 없을 수도 있습니다. )

이 중에서 함수의 이름을 제외한 나머지를 함수의 타입( function type )이라고 합니다.

int someFunc( int x, int y );	// 함수의 선언
int ( int, int );	// someFunc 함수의 타입

위의 someFunc 함수의 타입은 int (int,int)이므로, 이 함수에 대한 포인터는 다음과 같이 선언할 수 있습니다.

int ( *funcPtr )( int, int ){ &someFunc };	// 함수 포인터의 정의

int* funcPtr2( int, int );	// 함수의 전방 선언

int ( *funcPtr3 )( int, int ){ someFunc };	// 암시적인 변환

위에서, 함수 포인터 funcPtr 앞의 괄호를 제거하면, 다음 문장처럼 int* 타입을 반환하는 함수의 전방 선언을 뜻하는 것이 되므로 주의해야 됩니다.

그리고, someFunc 앞에 & 연산자를 사용하여, 이 함수의 주소를 얻어서 함수 포인터를 초기화하는 것이 기본이지만, 마지막 문장처럼, 컴파일러는 필요한 경우 함수를 함수 포인터로 암시적으로 변환하기 때문에, &를 사용하지 않더라도 잘못된 것은 아닙니다.

 

참고로, 함수의 프로토타입( prototype )은 함수의 선언( declaration )과 같은 말입니다.

그리고, 함수의 서명( signature )은 함수의 선언에서 반환 타입을 제외한 것으로, 이 서명에 해당하는 부분만이 함수를 오버로딩( overloading )할 때 고려되는 부분입니다.

int someFunc( int x, int y );	// sumeFunc( int, int ) 함수의 서명
bool someFunc( int x, int y ); // 이 두 함수의 서명은 동일

그래서, 위 두 함수는 다른 함수로 구분되지 않기 때문에, 오버로딩으로 보지 않고, 오류처리 합니다.


또한, 함수 포인터를 const 포인트로 선언하는 것은, 다른 포인터와 마찬가지로, * 뒤에 const를 붙이면 됩니다.

int* const ptr{ &var };	// const 포인터
int ( *const funcPtr )( int, int ){ &someFunc };	// const 함수 포인터

그리고, 이러한 const 함수 포인터는 함수 포인터가 가리키는 대상 함수를 변경할 수 없는 포인터를 말하며, const 멤버 함수와는 완전히 다른 개념입니다.

 

[C++] 객체를 변경할 수 없는 const 멤버 함수

const 멤버 함수C++에서는 기본 타입에 const 키워드를 붙여 값을 변경할 수 없는 변수를 정의할 수 있습니다. 그리고, 이러한 변수는 값을 변경할 수 없으므로, 정의할 때 반드시 초기화 과정을 거

codingbonfire.tistory.com

 

참고로, 함수 포인터나 함수 포인터로 암시적으로 변환되는 함수를 std::cout를 통해서 출력하면, 함수 포인터가 가리키는 함수의 주소를 출력할 것 같지만, C++은 함수 포인터 타입을 출력하는 방법을 모릅니다.

그리고, 이 경우 컴파일러는 함수 포인터 타입을 bool 타입으로 변환해서 출력합니다.

int add( int a, int b){
    return a + b;
}

int main(){

    int (*fncPtr)(int, int){ add };	// 함수 포인터
    
    std::cout << add << '\n';
    std::cout << fncPtr << '\n';
}

그런데, 위 fncPtr 포인터는 nullptr이 아니므로, 출력되는 값은 1이 됩니다.

 

C++ 표준 라이브러리는 객체의 주소를 출력하기 위해서, void* 타입에 대해 오버로딩 된 << 연산자를 제공하고 있기 때문에, 함수 포인터가 가리키는 함수의 주소를 출력하려면, 다음과 같이 함수 포인터의 타입을 변환해야 합니다.

int main(){

    int (*fncPtr)(int, int){ add };	// 함수 포인터
    
    std::cout << reinterpret_cast< void* >( add ) << '\n';
    std::cout << ( void* )fncPtr << '\n';   // C-style cast
}

▼출력

0x7ff7de4916df
0x7ff7de4916df

 

함수 포인터의 역참조( dereference )

포인터가 가리키는 객체의 값을 얻거나 변경하기 위해서 역참조 연산자 *를 사용하는 것과 마찬가지로, 함수 포인터가 가리키는 함수를 호출할 때도, *연산자를 사용합니다.

int add( int a, int b){
    return a + b;
}

int main(){

    int (*fncPtr)(int, int){ add };

    int val{ (*fncPtr)( 3, 5 ) };
    std::cout << val << '\n';

    val = fncPtr( 2, 4 );   // 암시적인 역참조
    std::cout << val << '\n';
}

위의 함수 포인터 fncPtradd 함수를 가리킵니다.

따라서, fncPtr에 역참조 연산자를 사용해서, add 함수에 접근할 수 있습니다.

그런데, fncPtr( 2, 4 )와 같이, 함수 포인터를 대상으로 () 연산자를 실행하게 되면, 컴파일러는 암시적으로 함수 포인터를 역참조해서 add 함수를 호출하기 때문에, * 연산자를 생략할 수도 있습니다.

 

함수 포인터의 별명( alias )

포인터를 함수에 전달하는 것과 마찬가지로, 함수 포인터를 함수에 전달하는 것은 당연히 가능합니다.

그런데, 문제는 이 방법에 필요한 코드가 꽤나 익숙하기 힘든 형태라는 것입니다.

 

아래는 add 함수를 다른 함수에 전달하는 예를 보여줍니다.

int add( int a, int b){
    return a + b;
}

int computeOperation( int a, int b, int (*operation)(int,int) ){
    return operation( a, b);
}

int main(){

    int var{ computeOperation( 2, 3, add ) }; // 함수 포인터를 전달
    std::cout << var << '\n';   
}

자세히 보면, 위의 computeOperation는 매개변수를 3개 가진 평범함 함수입니다.

그런데, 얼핏 보면 3개 이상의 매개변수를 가진 것처럼 보입니다.

 

이번엔 반대로, add와 같은 타입을 가진 함수를 반환하는 함수의 예를 보겠습니다.

enum operType{	// 기능의 종류
    oper_add, 
    oper_minus, 
    oper_multiply, 
    oper_divide,
};

// 함수 포인터를 반환하는 함수
int (*getOperation(operType type))(int, int){	
    return add;
}

int main(){

    int (*oper)(int,int){ getOperation(oper_add)};
    std::cout << oper( 3,4) << '\n';
}

위의 getOperation은 기능의 종류에 따라, 함수 포인터를 반환하는 함수입니다.

이러한 함수는 정의하기도 힘들고, 코드를 읽는 것도 힘든데, 사용하는 것조차 간단하지 않습니다.

 

다행인 것은, C++에서는 using 키워드를 이용해서, 함수 포인트 타입 같은 복잡한 타입에 대해 별명을 만들 수 있는 기능을 제공합니다.

int add( int a, int b){
    return a + b;
}

using OperPtr = int (*)(int, int);  // 타입의 별명

위의 OpenPtr은, add와 같은 함수를 가리키는, 함수 포인터 타입의 별명( alias )입니다.

이를 사용해서, 위의 코드들을 변경하면 다음과 같습니다.

int add( int a, int b){
    return a + b;
}

using OperPtr = int (*)(int, int);  // 타입의 별명


int computeOperation( int a, int b, OperPtr operation ){	// 별명을 사용
    return operation( a, b);
}

enum operType{
    oper_add, 
    oper_minus, 
    oper_multiply, 
    oper_divide,
};

OperPtr getOperation( operType type ){	// 별명을 사용
    return add;
}

int main(){

    int var{ computeOperation( 2, 3, add ) };
    std::cout << var << '\n';   

    OperPtr oper { getOperation(oper_add) };	// 별명을 사용
    std::cout << oper( 3,4) << '\n';
}

위에서 보듯이, 함수 포인터 타입의 별명을 사용하면, 우선 복잡한 코드( 괄호의 중복, 매개변수의 위치와 개수, 포인터임을 나타내는 기호...)를 작성해야 하는 문제에서 벗어날 수 있을 뿐 아니라, 함수 포인터 타입에 이름을 붙일 수 있게 돼서, 이 타입의 사용 목적을 좀 더 명확하게 전달할 수 있게 됩니다.

 

함수 포인터의 타입 추론( type deduction )

C++은 auto 키워드를 사용하여, 변수의 초기값으로부터 변수의 타입을 추론할 수 있습니다.

 

[C++] 자동으로 변수의 타입을 완성하는 auto 키워드

변수의 타입 추론( type deduction )C++ 에는 모든 값에 해당하는 타입이 있습니다.int main(){ 5; // int 3.14; // double 5L; // long 5.0f; // float ?? variable{ 5 }; // variable의 타입 ??}예를 들어, 위의 3.14는 double 타입을

codingbonfire.tistory.com

그리고 다음과 같이, 변수의 하나인 포인터( pointer )의 타입도 당연히 추론할 수 있습니다.

#include <array>

int main(){

    std::array<int, 5 > arr{ 1, 2, 3, 4, 5 };
    auto arrPtr{ &arr };    // std::array< int, 5 >* 타입
}

위에서는 arrPtrstd::array<int,5>* 타입으로 추론합니다.

 

함수 포인터도 이와 같은 방식을 통해, 기다란 타입을 입력하지 않고도 타입을 완성할 수 있습니다.

#include <array>

auto getSize( const std::array<int, 5 >& arr){
    return arr.size();
}

int main(){

    std::array<int, 5 > arr{ 1, 2, 3, 4, 5 };

    using getSizePtr = std::size_t (*)( const std::array<int, 5>& );
    getSizePtr getPtr1{ getSize }; // 함수 포인터 타입의 별명을 이용
    
    auto getPtr2{ getSize }; // 함수 포인터 타입의 추론
    std::cout << getPtr2( arr ) << '\n';
}

위의 경우 함수 포인터의 별명을 사용하는 것보다, auto를 사용하여, getSize의 함수 타입을 추론하는 것이 좀 더 편리하다는 것을 볼 수 있습니다.

( 물론, 위의 경우는 함수 포인터의 선언이 힘들다는 것과 auto를 통해서 이러한 부담을 조금 덜 수 있다는 것을 얘기하기 위해서 만든 예입니다. 이런 경우 당연히 arr.size() 함수를 사용하는 것이 제일 좋습니다. )

 

함수 포인터의 배열

포인터를 배열에 담는 것과 마찬가지로, 함수 포인터 또한 배열에 담을 수 있습니다.

int add(int a, int b){
    return a+b;
}

int subtract(int a, int b){
    return a-b;
}

int multiply(int a, int b){
    return a*b;
}

int divide(int a, int b){
    if ( b == 0) 
        return 0;
    else
        return a / b;
}

int main() {

    int a{}, b{}, c{};
    int* arrPtr[3]; // 포인터의 배열
    arrPtr[0] = &a;
    arrPtr[1] = &b;
    arrPtr[2] = &c;
    
    int (*operation[4])(int, int);  // 함수 포인터의 배열
    operation[0] = &add;
    operation[1] = subtract;    // &이 없어도 ok
    operation[2] = multiply;
    operation[3] = divide;
}

 

그렇지만, 별명을 사용하여 좀 더 알아보기 쉬운 선언을 하는 것이 좋습니다.

enum operType{	// 범위없는 enum
    oper_add, 
    oper_minus, 
    oper_multiply, 
    oper_divide,
    oper_num,
};

using OPERATION = int (*)(int, int);	// 함수 포인터의 별명

int main() {

    OPERATION oper[oper_num];  // 함수 포인터의 별명
    oper[oper_add] = &add;
    oper[oper_minus] = subtract;    // &이 없어도 ok
    oper[oper_multiply] = multiply;
    oper[oper_divide] = divide;
}

혹은, 다음과 같이 4개의 원소를 가진 배열을 별명으로 만들 수도 있습니다.

using OPERATION4 = int (*[4])(int, int);	// 함수 포인터 배열의 별명

int main() {

    OPERATION4 oper{ add, subtract, multiply, divide };
    std::cout << oper[0]( 3, 4) << '\n';
}

 

 

함수 포인터를 사용하는 이유

함수 포인터( function pointer )를 사용하는 가장 큰 장점은 함수에 또 다른 함수를 포인터 형태로 전달할 수 있다는 것입니다.

이렇게 어떤 함수에 전달되는 다른 함수를 콜백( callback ) 함수라고 부르며, 콜백 함수를 전달받은 함수는 함수 내부에서 이 콜백 함수를 다시 호출함으로써 자신의 목적을 달성합니다.

 

이러한 방식의 접근은 함수들을 모듈화 하고 재사용하기 수월하게 만듭니다.
또한, 함수에 전달되는 콜백 함수를 바꿈으로써, 실시간에 수행되는 기능의 동적인 변화를 가져올 수 있습니다.

아래는 사용자의 입력에 따라, printResult 함수에, 실행하고자 하는 콜백 함수를 전달하는 예문입니다.

enum operType{	// 기능의 종류, unscoped enum
    oper_add, 
    oper_minus, 
    oper_multiply, 
    oper_divide,
    oper_num,
};

int add(int a, int b){
    return a+b;
}

int subtract(int a, int b){
    return a-b;
}

int multiply(int a, int b){
    return a*b;
}

int divide(int a, int b){
    if ( b == 0) 
        return 0;
    else
        return a / b;
}

using OPERATION = int (*)(int, int);	// 함수 포인터의 별명

void printResult( int a, int b, OPERATION oper){
    
    int ret = oper(a,b);
    std::cout << "operation result: " << ret << "\n";
}

int main() {

    int num, a, b;
    std::cin >> num >> a >> b;

    if ( num > oper_num ) return 0;

    OPERATION operation[ oper_num ]{ add, subtract, multiply, divide };

    printResult(a, b, operation[num] );   // 함수 포인터를 인자로 전달
}

위의 printResult 함수는 함수 포인터의 형태로 전달된 함수를 실행하고, 그 결과를 출력할 뿐, 전달된 함수가 어떤 기능을 수행하는지 알 필요가 없습니다.

이렇게 각 함수의 기능을 작게 나누고 고립시킬수록, 이러한 함수들을 작성하고 재사용하기 쉬워지는데, 이런 것들이 가능하려면 함수의 전달이라는 방법을 통해, 기능의 조립이 가능해야 합니다.

그리고, C++에서는 함수 포인터를 이용해서 함수를 전달할 수 있습니다.

 

 

정리

  • 함수의 주소를 저장하는 변수를 함수 포인터( function pointer )라고 합니다.
  • 함수 포인터는 함수 타입( function type )과 *기호를 사용하여 선언됩니다.
  • 함수 포인터의 복잡한 타입은, ( using을 이용한 ) 타입의 별명을 정의하여 사용하기 쉽게 만들 수 있습니다.
  • 함수 포인터 사용의 목적은 기능의 전달입니다.

 

 

이 글과 관련 있는 글들

몇 가지 값만 가질 수 있는 타입: unscoped enum

const 포인터( pointer )의 종류

호출 가능한 객체를 저장하는 std::function

 

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유