티스토리 뷰

/* 본 포스팅은 최호성 저자의 '독하게 시작하는 C프로그래밍' 교재를 참조로 작성되었음을 먼저 알려드립니다. */


 함수의 이름 또한 배열의 이름처럼 '주소 상수'에 부여한 식별자다. 따라서 함수의 이름도 포인터 변수에 저장할 수 있다. 다만 변수의 자료형이 함수 호출에 필요한 정보들을 포함하고 있어야 변수를 이용해서 함수를 호출할 수 있다. // 호출에 필요한 정보는 매개 변수, 호출 규칙, 반환 자료형 등이 있다.


 그래서 'void *'에 함수의 이름을 저장할 수는 있으나 호출을 할 수는 없다. 아래의 코드는 함수 포인터를 구체적으로 다루기에 앞서 함수의 이름이 주소임을 확인하기 위한 코드다.

#include <stdio.h>

int main(void)
{
	// void *는 어떤 주소든 담을 수 있는 자료형이다.
	// 함수이름은 '주소' 이므로 void *에 담을 수 있다.
	void *pData = main;

	// 함수의 주소를 출력한다.
	printf("%p\n", pData);
	printf("%p\n", main);
	return 0;
}

 7번 행에서 void형 포인터인 pData를 선언 및 정의하는데 있어 초깃값으로 'main'을 기술했다. pData에 들어 있는 주솟값이 main 이므로 10번 행과 11번 행은 같은 결과를 출력한다. 이러한 주소와 매개변수, 호출 규칙을 알면 일단 함수를 호출할 수 있다. 


 변수도 함수도 사실은 다 주소로 식별을 한다. 다만 그 주소의 메모리를 저장공간으로 활용할 것인가, 아니면 그 주소의 메모리에 저장된 정보를 CPU가 인식하는 명령코드로 볼 것인가만 다를 뿐이다.



# 함수 포인터


 함수 포인터 변수의 문법은 약간 복잡하다. 다차원 배열에 대한 포인터처럼 '*'가 기술되는 위치가 중간에 기술되며 반드시 괄호로 묶어야 한다. 우선 기본 형식은 아래와 같다.


반환 자료형 ( 호출 규칙 *변수 이름) (매개 변수)


 아래의 코드는 매개변수로 char *형 자료 한 개와 int형 정보 0을 반환하는 함수를 main( ) 함수에서 호출하는 코드다. 

#include <stdio.h>

int testFunc(char *pszParam)
{
	return 0;
}

int main(void)
{
	// 괄호는 함수 호출 연산자
	testFunc("");

	// 저장하는 행위 자체는 허용이 된다.
	// 다만 자료형이 맞지 않는다.
	int nData = testFunc;

	nData("");
	return 0;
}

 다소 난해하게 보이는 이 코드는 일단 컴파일 에러가 난다. int nData = testFunc; 까지는 허용이 되지만 nData(""); 부분에서 오류가 난다. 주소 상수 testFunc를 int형 변수에 저장하는 건 되지만 이걸로 함수 호출까지는 되지 않는다는 걸 확인할 수 있다. 하지만 아래와 같이 코드를 수정하면 어떻게 될까?

#include <stdio.h>
// 함수 포인터를 사용하는 기본 형식
//int (*이름) (char*)
int testFunc(char *pszParam)
{
	return 0;
}

int main(void)
{
	// 괄호는 함수 호출 연산자
	testFunc("");

	// 저장하는 행위 자체는 허용이 된다.
	// 다만 자료형이 맞지 않는다.
	int nData = (int)testFunc;

	// 강제 형변환을 통해 함수호출을 할 수 있는 구조로
	// 만들어주면 호출이 가능하다.
	((int(*)(char*))nData)("");
	return 0;
}

 정상적으로 컴파일이 되고 함수 호출까지 된다. 변수에 대한 이해도도 높일 수 있기에 좋은 코드라고 할 수 있다. 변수도 결국 메모리고 어떻게 해석하는지에 따라 용도가 얼마든지 달라질 수 있다. 비록 int 형 변수지만 함수를 호출할 수 있는 코드로 사용할 수 있다는 건 어찌보면 당연한 얘기다. 


 함수 호출 포인터의 문법이 이렇다는 점과 변수와 함수 이름 등은 모두 메모리에 기록된 상수이며 어떻게 해석하는지에 따라 달라진다는 점 또한 다시한 번 상기하자. 아래의 코드는 함수 포인터를 이용하여 함수를 호출하는 문법을 다시한 번 보여준다. 

#include <stdio.h>

// 함수 포인터를 사용하는 기본 형식
//int (*이름) (char*)
int testFunc(char *pszParam)
{
	return 0;
}

int main(void)
{
	int(__cdecl *pfTestFunc)(char *) = testFunc;
	pfTestFunc("pszParam");
	return 0;
}

 다차원 배열에 대한 포인터처럼 (호출규칙 *이름)이 괄호로 묶이는 것 때문에 조금 복잡해 보이는 것 뿐, 나머지는 함수 본래의 반환 자료형과 매개변수 자료형을 그대로 기술하면 된다.


 그리고 늘상 봐오던 함수호출 연산자에 대해서도 언급을 하자면, 함수호출 연산자는 바로 괄호이며 주소( 매개변수 ) 형식으로 사용한다. 여기서 주소는 물론 함수 이름이다. 혹은 함수 포인터 일 수도 있다. 바로 위의 코드는 함수 호출을 함수 포인터로 하는 것에 대한 예제 코드다.



# 역호출 구조


함수호출 연산자나 함수 포인터가 꼭 필요한 경우가 언제인지에 언급하자면 아래의 두 경우가 있다.

  • 동적 연결 라이브러리(DLL, Dynamic Linking Library)를 활용하는 경우

  • 역호출(call back) 구조를 구현하는 경우
 이 중 역호출에 대해 다뤄보고자 한다. 지금까지 우리가 사용해 왔던 함수 호출은 주로 직접 호출하는 능동적인 방법이었다. 그런데 이 함수라는 것이 작성자 입장에서 "내가 호출하는 것이 아니라 다른 무엇에 의해 호출" 될 수도 있다. 간단한 예로 아래와 같은 코드를 살펴보자.
#include <stdio.h>

// 함수 포인터를 사용하는 기본 형식
//int (*이름) (char*)
int testFunc(char *pszParam)
{
	return 0;
}

// 매개변수로 함수 포인터와 int형 변수를 받는 함수
void myTest(int(*pfTest)(char*), int nParam)
{

}

int main(void)
{
	myTest(testFunc, 10);
	return 0;
}

정말 단순하게 생각해보면 난 testFunc( ) 함수를 만든 제작자다. 하지만 main함수에서 testFunc( )를 직접 호출하는 것이 아니라 myTest( ) 함수를 통해 간접적으로 호출이 되고 있다. 즉, 다른 무엇에 의해 호출 되는 경우인 것이다.


 역호출 구조를 좀 더 제대로 이해하기 위해서는 qsort( ) 함수에 대해 공부하는 게 도움이 된다. qsort( ) 함수에 대해 먼저 살펴보면 아래와 같다.


void qsort(void *base, size_t num, size_t width, int(__cdecl *compare)(const void *, const void *));

  • 인자
    base:    정렬 대상 배열의 기준주소

    num:    배열 요소의 개수
    width:    배열 요소의 바이트 단위 크기
    compare:    각 요소를 비교하여 같을 경우 0, 다를 경우 양수 혹은 음수를 반환하는 함수의 주소

  • 반환값: 없음

  • 설명: 퀵 정렬 알고리즘을 이용하여 배열에 담긴 요소를 정리하는 함수이다. 사용자 정의 콜백 함수를 만들어 인수를 전달하는 방법으로 각 항을 비교하는 방법을 구체화 한다.
 아래의 예제는 CRL 함수인 qsort( )의 사용 에를 보인 것이다. qsort( ) 함수의 네 번째 매개 변수는 const void* 둘을 매개변수로 받고 int형 자료를 반환하는 함수에 대한 포인터이다. // 퀵 정렬은 평균 정렬 속도가 가장 빠른 것으로 알려진 정렬 알고리즘이다. CRL은 C Runtime Library의 약자이다.
#include <stdio.h>
#include <stdlib.h>

// qsort() 함수가 역호출할 함수의 선언 및 정의
// 각 항을 비교하는 함수
int myCompare(const void *left, const void *right)
{
	// void *로 매개변수를 받으면 사용할 수 없으므로
	// int * 라고 명시해준다.
	return *(int*)left - *(int*)right;
}

// 메인함수가 직접 myCompare를 호출하지 않는다.
int main(void)
{
	int aList[5] = { 20, 50, 10, 30, 40 };

	qsort(aList, 5, sizeof(int), myCmpare);

	for (int i = 0; i < 5; i++)
		printf("%d\t", aList[i]);
	return 0;
}

 myCompare 함수의 경우 정렬의 대상이 int형 혹은 double형이 될 수도 있으므로 qsort( ) 함수는 자료형을 구체화하지 않고 일반적 개념 (myCompare(void *, void *))로 정렬을 구현한 것이다. 따라서 일반화된(혹은 추상화된) 개념을 구체화하는 것은 qsort( ) 함수를 사용하려는 사람의 몫이다.


 구체화하는 방법은 "각 항을 비교하여 결과를 반환하는 함수"를 만드는 것이다. 그리고 void *에 대해 간접지정연산을 통한 인스턴스화가 불가능하므로, myCompare의 return 문에서 const void *를 int *로 강제 형변환 한 후 간접지정 연산을 수행했다. // void *는 자료형을 해석하지 않겠다는 의미로 해석되기 때문에 반환을 할 수 없다. 반환이라는 건 어떤 값을 어떻게 해석해서 내보내겠다는 의미인데 해석을 안하는 void *를 가지고 반환할 수는 없는 노릇이다.


 위의 코드는 메인 함수에서 qsort( ) 함수를 호출하고, qsort( ) 함수가 myCompare( ) 함수를 역호출(call back)하는 구조를 가지고 있다.


 위의 예제의 코드만 봐서는 myCompare( ) 함수가 몇 번이나 역호출 되는지는 알 수 없다. myCompare( ) 함수는 qsort( ) 함수가 정렬을 위해 두 항을 비교할 때마다 호출될 것이므로 알고리즘을 정확히 알고 있는 사람이라면 예측할 수도 있을 것이다. 그러나 그런 내용을 전혀 알지 못하면, myCompare( ) 함수는 '언제 그리고 몇 번' 호출되는지 알 수 없다.


 역호출(call back) 이란 이름이 붙은 이유도 qsort( ) 함수의 호출자는 main( ) 함수지만 피호출자인 qsort( ) 함수가 다시 myCompare( ) 함수의 호출자가 되기 때문이다.


 만약 main( ) 함수의 제작자가 A 라고 한다면 myCompare( ) 함수의 제작자 역시 A라고 할 수도 있다. 그러나 qsort( ) 함수의 제작자가 누군지는 몰라도 그는 A가 아닐 것이라 생각해 볼 수는 있다. B라고 가정한다면, 최초 A가 만든 main( ) 함수가 B가 만든 qsort( ) 함수를 호출한다. 이 때, A는 자신이 만든 함수의 주소를 B에게 전달하고 B는 자신의 코드를 수행하다가 A가 알려준 주소의 함수를 '역으로 호출(call back)' 한다. // 함수 주소를 등록한다는 개념이다. 이 등록을 받은 쪽에서 주소를 아니까 call 을 해준다는 의미다. 등록을 한 대상으로부터 called 된다 라고 생각하면 된다.


 위 예제에서는 main( ) 함수가 myCompare( ) 함수를 호출하는 코드가 없다. 그럼에도 myCompare( ) 함수는 분명히 호출된다. 즉, 피호출자 함수에 의해 역으로 호출된다. "내가 호출하는 것이 아니라 자동으로 호출된다" 라는 개념은 앞으로도 대단히 중요하다. 


 객체지향언어에서도 대단히 중요한데, 문법적으로 함수 포인터가 등장하는 것은 아니지만 함수가 자동으로 호출된다는 가정에 기반을 둔 문법이 존재하고, 이를 명확히 이해하고 제대로 사용하려면 반드시 "자동으로 호출된다"라는 의미를 활용할 수 있어야 한다. // 콜백은 주로 유저(user)모드와 커널(kernel)모드의 개념에서도 쓰인다. 유저가 만든 함수를 커널에 등록하면 OS가 비동기적으로 이 함수를 call back 한다. 언제 부르고 왜 부르고 몇 번 불렀는지는 모른다.


 그렇다면 qsort( ) 함수를 왜 callback으로 만들었을까? 정렬하는 대상 자료형이 계속 달라질 수 있기 때문이다. 자료형에 따라 비교를 하는 방법을 다양하고 전부 다 다르다. 따라서 정렬의 대상은 사용자가 직접 정해야 한다. 전체적인 정렬의 방법(인터페이스)은 제공해주고, 직접적인 적용인 비교 logic은 비교하고 싶은 사람이 알아서 규정하게끔 콜백 구조가 만들어지는 것이다. // 인터페이스의 역할을 한다고 생각하면 콜백을 이해하기 한결 쉽다.


 

# 함수포인터 + (Look up) 배열


 이 기법을 사용하면 switch 와 case 문을 완벽하게 대처하면서 고성능으로 코드 제작이 가능하다. 앞서 말한 함수 포인터와 조합해서 사용하면 아래와 같은 기법이 가능하다. // 상당한 고급 테크닉으로 분류된다. 실제로 switch case를 쓰는 것보다 성능이 정말 많이 향상된다.

#include 

void testFunc1(int nParam)
{
	puts("testFunc1");
}
void testFunc2(int nParam)
{
	puts("testFunc2");
}
void testFunc3(int nParam)
{
	puts("testFunc3");
}

int main(void)
{

	void(*pfList[3])(int) = {	// 함수 포인터의 배열이다.
		testFunc1, testFunc2, testFunc3
	};

	// 룩업 테이블처럼 인덱스를 입력 받아서 한다고 생각해보면
	// switch case를 할 필요가 없이 신속하게 함수를
	// 호출할 수 있다. 성능에 큰 도움이 된다.
	int nInput;
	scanf_s("%d", &nInput);

	if(nInput >= 0 && nInput <= 2)
		pfList[nInput](10);
	
	return 0;
}


댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday