본문 바로가기

C++ Programming

함수 템플릿

C++은 여러 가지 개발 방법을 지원하는 멀티 패러다임 언어라고 하는데 적어도 다음 세 가지 방법으로 개발을 할 수 있다.

 

① 구조적 프로그래밍 : C언어에서와 마찬가지로 함수 위주로 프로그램을 작성할 수 있다. C++이 C언어의 계승자이므로 C언어의 개발 방법을 지원하는 것은 당연하다.

② 객체 지향 프로그래밍 : 캡슐화, 추상화를 통해 현실 세계의 사물을 모델링할 수 있으며 상속과 다형성을 지원하기 위한 여러 가지 언어적 장치를 제공한다.

③ 일반화 프로그래밍 : 임의 타입에 대해 동작하는 함수나 클래스를 작성할 수 있다. 객체 지향보다 재사용성과 편의성이 더 우수하다.

 

일반화 프로그래밍은 주로 C++ 템플릿에 의해 지원되며 C++ 표준 라이브러리가 일반화의 좋은 예이다. 템플릿은 C++이 일반화를 위해 제공하는 가장 기본적인 문법이므로 템플릿에 대한 이해는 C++ 표준 라이브러리인 STL을 이해하기 위한 문법적 토대가 된다. 개념은 간단하지만 실제 적용될 때는 굉장히 복잡한 형태를 띄기 때문에 원리를 이해하는 것이 중요하다.

템플릿(Template)이란 무엇인가를 만들기 위한 형틀이라는 뜻이다. 플라스틱 모형을 만들기 위한 금형이라든가 주물을 만들기 위한 모래틀이 형틀의 예이며 좀 더 이해하기 쉬운 예를 들자면 붕어빵을 만드는 빵틀을 들 수 있다. 템플릿은 모양에 대한 본을 떠 놓은 것이며 한 번만 잘 만들어 놓으면 이후부터 재료만 집어 넣어서 똑같은 모양을 손쉽게 여러 번 찍어 낼 수 있다. 길거리의 붕어빵 장사들을 보면 빵틀에 밀가루와 팥만 집어 넣어서 똑같이 생긴 붕어빵을 얼마든지 찍어 내고 있지 않은가?

템플릿의 또 다른 특징은 집어 넣는 재료에 따라 결과물들이 조금씩 달라진다는 것이다. 금형에 플라스틱을 집어 넣으면 플라스틱 제품이 나오고 고무를 집어 넣으면 고무로 된 제품을 만들 수 있다. 방틀에도 밀가루를 넣으면 붕어빵이 나오지만 찹쌀 가루를 넣으면 잉어빵이라는 좀 더 부가가치가 높은 상품이 만들어진다. 제품의 모양만 같을 뿐이지 내용물은 조금씩 달라지는 것이다.

함수 템플릿은 함수를 만들기 위한 형틀이라고 생각하면 된다. 비슷한 모양의 함수들을 여러 개 만들어야 한다면 각 함수들을 매번 직접 정의할 필요없이 함수 템플릿을 한 번만 만들어 놓고 이 템플릿으로부터 일련의 함수들을 찍어낼 수 있다. 다음 예제는 일정한 타입의 변수 두 개의 값을 교환하는 Swap 함수를 만든다.

 

  : SwapFunc

#include <Turboc.h>

 

void Swap(int &a, int &b)

{

     int t;

     t=a;a=b;b=t;

}

 

void Swap(double &a, double &b)

{

     double t;

     t=a;a=b;b=t;

}

 

void main()

{

     int a=3,b=4;

     double c=1.2,d=3.4;

     Swap(a,b);

     Swap(c,d);

     printf("a=%d,b=%d\n",a,b);

     printf("c=%f,d=%f\n",c,d);

}

 

main에서 변수 여러 개를 선언한 후 Swap 함수로 값을 교환하고 확인을 위해 출력했다. 정수형, 실수형 변수들이 애초에 선언된 값과 반대로 바뀌어 있음을 확인할 수 있다.

 

a=4,b=3

c=3.400000,d=1.200000

 

두 값을 교환하는 알고리즘은 무척 간단해서 두 변수의 값을 서로 대입하기만 하면 된다. 단, 먼저 대입받는 변수의 값을 잠시 저장해 놓기 위한 임시 변수 하나가 필요하며 실인수의 값을 바꿔야 하므로 포인터나 레퍼런스를 이용한 참조 호출을 해야 한다. 음료수잔의 콜라, 사이다를 교환하고 싶다면 빈 컵 하나가 반드시 필요하며 교환 대상에 따라 빈 컵의 모양과 크기도 달라야 한다. 음료수를 교환하기 위한 빈컵으로 소주잔은 적당하지 못하다. 예제에는 정수에 대한 Swap, 실수에 대한 Swap 함수가 작성되어 있는데 교환 대상의 타입이 달라지더라도 알고리즘은 동일하며 본체 내용 중 달라지는 부분은 인수와 임수 변수의 타입 뿐이다.

int와 double외에 char, long, 사용자 정의 구조체 등의 변수들도 교환해야 한다면 각 타입에 대해서도 Swap 함수를 일일이 만들어야 할 것이다. 알고리즘은 같지만 인수와 임시 변수의 타입이 다르므로 한 함수로 임의 타입의 변수를 교환할 수는 없다. 그나마 C++은 오버로딩을 지원하므로 함수의 이름이라도 똑같이 작성할 수 있지만 C에서는 함수의 이름마저도 SwapInt, SwapDouble 등으로 달라야 한다. 이런 비슷한 함수들을 일일이 만들어야 한다는 것은 무척 짜증나는 일이며 만든 후에 수정하기도 번거롭다. 그래서 이 함수들을 통합할 수 있는 여러 가지 방법들을 생각해 볼 수 있다.

 

 우선 인수의 타입을 #define이나 typedef로 정의한 후 본체에서는 이 매크로를 참조하는 방법을 생각할 수 있다. 교환 대상에 대한 중간 타입을 정의하고 함수에서는 중간 타입을 사용하는 것이다. 필요할 때마다 매크로의 타입 정의를 바꾸면 임의의 타입에 대해 교환하는 함수를 만들 수 있다. 다음이 그 예이다.

 

#define SWAPTYPE int

void Swap(SWAPTYPE &a, SWAPTYPE &b)

{

     SWAPTYPE t;

     t=a;a=b;b=t;

}

 

SWAPTYPE이 int로 정의되어 있으므로 현재 Swap 함수는 int형 변수값을 교환하지만 SWAPTYPE을 double로 바꾸면 실수를 교환하는 함수로 탈바꿈할 것이다. 그러나 이 방법은 컴파일할 때마다 필요한 타입으로 바꿔야 한다는 점이 불편하다. 쉽게 말해서 자동이 아니라 수동이다. 또한 이 방법은 하나의 매크로가 두 개의 값을 가질 수 없으므로 각 타입을 교환하는 함수가 동시에 두 개 이상 존재할 수 없다는 점이 문제다.

 두 번째로 다음과 같은 매크로 함수를 쓰는 방법도 가능하다. 중간 타입을 쓰는 것이 아니라 아예 함수 자체를 매크로로 만들어서 필요할 때마다 전개하는 방식이다.

 

#define SWAP(T,a,b) { T t;t=a;a=b;b=t; }

 

이 매크로 함수는 잘 동작하기는 하지만 매크로내에서 임시 블록 변수 t를 선언해서 사용하므로 교환 대상의 타입을 일일이 가르쳐 줘야 한다. 그래야 임시 변수 t의 타입을 결정할 수 있다. 정수값 a와 b를 바꾸려면 SWAP(int, a, b)로 호출해야 하는데 첫 번째 인수로 전달되는 int라는 타입이 왠지 불편해 보이고 최소 의사 표시 원칙에도 맞지 않다. 군더더기없이 교환하고자 하는 대상만 지정할 수 있어야 한다.

또한 매크로 함수는 치환될 때마다 코드가 반복되므로 프로그램이 커지는 고질적인 문제가 있다. 그래서 복잡한 동작을 하는 함수에는 부적합하며 값을 교환하는 SWAP 정도의 초간단 함수에만 적용할 수 있다. 게다가 매크로 함수는 여러 가지 부작용도 많아 일반적인 용도로 쓰기에는 한계가 있다.

 이외에 void *라는 일반적인 포인터 타입을 쓰는 방법도 있다. void *는 임의의 타입을 가리킬 수 있으므로 교환 대상 변수의 번지를 전달하여 메모리 복사하는 방식으로 두 값을 교환할 수 있다. 실제로 이런 방식이 가능한지 예제를 만들어 보자.

 

  : SwapVoid

#include <Turboc.h>

 

void Swap(void *a,void *b,size_t len)

{

     void *t;

     t=malloc(len);

     memcpy(t,a,len);

     memcpy(a,b,len);

     memcpy(b,t,len);

     free(t);

}

 

void main()

{

     int a=3,b=4;

     double c=1.2,d=3.4;

     Swap(&a,&b,sizeof(int));

     Swap(&c,&d,sizeof(double));

     printf("a=%d,b=%d\n",a,b);

     printf("c=%f,d=%f\n",c,d);

}

 

실행 결과는 앞의 예제와 완전히 동일한데 두 개의 함수를 만들지 않아도 한 함수로 정수형과 실수형을 모두 교환할 수 있다. 함수가 포인터를 요구하므로 호출측에서는 교환대상에 일일이 &를 붙여 번지를 넘기고 또한 길이도 같이 전달해야 한다. void &라는 것은 없으므로 임의 타입을 전달할 때는 레퍼런스를 쓸 수 없고 포인터만 가능하다. void *는 임의의 변수가 있는 번지를 가리킬 수 있어 타입에 대한 정보는 불필요하지만 대신 길이에 대한 정보가 없으므로 길이도 같이 전달하는 수밖에 없다.

Swap 함수 내부도 다소 복잡한데 임의의 타입을 교환해야 하므로 단순한 대입으로는 값을 교환할 수 없으며 변수가 차지하고 있는 영역끼리 메모리 복사를 통해 교환해야 한다. 이때 교환을 위한 임시 변수도 반드시 동적으로 할당해야 하는 부담이 있는데 교환 대상의 길이를 전혀 예측할 수 없으므로 충분한 길이의 임시 버퍼로는 안전하지 않다. 16바이트 정도면 왠만한 기본 타입은 다 교환할 수 있겠지만 1000바이트짜리 구조체가 전달될지도 모르기 때문에 동적으로 할당해야 한다. 이 방법대로라면 아주 큰 배열까지도 교환할 수 있다.

void *를 이용한 교환 함수는 나름대로 실용성도 있고 그야말로 임의의 타입을 다룰 수 있다는 점에서 훌륭하다. 실제로 이런 함수는 종종 사용되며 템플릿보다 더 우월한 면도 있다. 하지만 일일이 &를 붙여 번지를 전달해야 하고 길이까지 가르쳐 주어야 한다는 점에서 불편하기는 마찬가지이다.

 

여러 가지 대안들이 있지만 신통하게 마음에 드는 방법은 딱히 없다. 지금까지 이 문제에 대한 전통적인 해결방법은 복사한 후 원하는 부분을 수정하는 이른바 몸으로 떼우기 작전밖에 없었다. 약간의 수고만 감수하면 Swap(int, int)를 복사한 후 Swap(double, double)이나 Swap(unsigned, unsigned)를 얼마든지 만들 수 있다. 복사된 수만큼 함수가 늘어나기는 하지만 적어도 호출할 때마다 함수의 본체가 반복되지는 않으며 완전한 함수이므로 지역변수를 자유롭게 쓸 수 있고 복잡한 동작도 얼마든지 가능하다. & 연산자가 없어도 되며 길이 정보도 전달할 필요가 없다.

그러나 이런 전통적인 방법은 필요한 타입이 늘어날 때마다 사람의 작업을 필요로 하므로 생산성이 떨어지며 또한 일부를 수정하지 않는 실수의 가능성이 있어 위험하기도 하다. 이런 복사 후 수정 작업을 컴파일러가 대신 하는 문법적 장치가 바로 함수 템플릿이다. 원하는 함수의 모양을 템플릿으로 등록해 두면 함수를 만드는 나머지 작업은 컴파일러가 알아서 한다. 다음 예제는 Swap 함수를 템플릿으로 정의한 것이다.

 

  : SwapTemp

#include <Turboc.h>

 

template <typename T>

void Swap(T &a, T &b)

{

     T t;

     t=a;a=b;b=t;

}

 

struct tag_st {int i; double d; };

void main()

{

     int a=3,b=4;

     double c=1.2,d=3.4;

     char e='e',f='f';

     tag_st g={1,2.3},h={4,5.6};

 

     printf("before a=%d, b=%d\n",a,b);

     Swap(a,b);

     printf("after a=%d, b=%d\n",a,b);

     Swap(c,d);

     Swap(e,f);

     Swap(g,h);

}

 

Swap 함수 템플릿을 정의한 후 정수, 실수, 문자열, 구조체 등에 대해 Swap 함수를 호출해 보았다. 임의의 타입에 대해 Swap 함수를 사용할 수 있되 단 함수 내에서 지역적으로 선언된 타입은 사용할 수 없다. 지역 타입은 함수 내부에서만 쓰는 것이므로 함수간의 통신에는 사용할 수 없기 때문이다. 그래서 tag_st 구조체를 전역으로 선언했는데 이 구조체 선언문이 main 함수 안에 포함되면 에러로 처리된다. 모든 타입에 대해서 제대로 동작하는데 정수형의 a, b에 대해서만 결과를 확인해 보았다. 실행 결과는 다음과 같다. 나머지 타입들도 출력해 보면 잘 교환될 것이다.

 

before a=3, b=4

after a=4, b=3

 

함수 템플릿을 정의할 때는 키워드 template 다음에 <> 괄호를 쓰고 괄호안에 템플릿으로 전달될 인수 목록을 나열한다. 템플릿 인수 목록에는 키워드 typename 다음에 함수의 본체에서 사용할 타입의 이름이 오는데 함수의 형식 인수와 비슷한 기능을 한다고 생각하면 된다. 이 이름은 명칭 규칙에만 맞으면 마음대로 작성할 수 있으나 일반적으로 T나 Type이라는 짧은 이름을 많이 사용한다. 이어지는 함수의 본체에서 템플릿 인수를 참조하여 구체적인 코드를 작성한다.

함수 호출부에서 int 타입을 사용했으면 T는 int가 되며 함수 본체에서 참조하는 T는 모두 int가 될 것이다. 마찬가지로 double이 전달되면 T는 double이 되고 char가 전달되면 T는 char가 된다. 호출부에서 전달되는 실제 타입을 템플릿 정의에서 표기하기 위한 임시적인 이름이 바로 typename T인 것이다. 템플릿이 빵틀이라면 T는 빵틀에 집어넣는 재료에 비유될 수 있다.

템플릿 인수 목록에는 키워드 typename 대신 class를 쓸 수도 있으며 구형 컴파일러들은 이 자리에 class를 사용했었다. template <typename T>와 template <class T>는 같은 표현이다. 어차피 클래스도 타입이고 int, double 등도 일종의 클래스이므로 의미상 틀리지는 않지만 이렇게 되면 반드시 클래스 타입만 가능한 것처럼 보여 오해의 소지가 있다. 그래서 새로 개정된 표준에는 좀 더 일반적인 의미를 가지는 typename이라는 키워드가 새로 도입되었으며 가급적이면 class 대신 typename을 사용하는 것이 좋다. 현재 class라는 키워드는 클래스를 정의할 때만 쓰도록 권장된다.

템플릿이란 컴파일러가 미리 등록된 함수의 형틀을 기억해 두었다가 함수가 호출될 때 실제 함수를 만드는 장치이다. 그렇다면 다음과 같이 함수를 만드는 매크로 함수를 정의하는 것과는 어떤 점이 다를까?

 

#define MakeSwap(T) \

void Swap(T &a, T &b)\

{\

     T t;\

     t=a;a=b;b=t;\

}

 

struct tag_st {int i; double d; };

MakeSwap(int)

MakeSwap(double)

MakeSwap(char)

MakeSwap(tag_st)

 

MakeSwap 매크로 함수로 타입 T를 전달하면 T 값 두 개를 교환하는 함수 Swap이 만들어진다. 문법적으로는 분명히 가능한 방법이며 템플릿과 개념상 비슷하지만 두 방법은 지원 주체의 레벨이 다르다. 매크로 함수는 전처리기가 처리하지만 템플릿은 컴파일러가 직접 처리한다. 전처리기는 지시대로 소스를 재구성할 뿐이므로 개발자가 필요한 타입에 대해 일일이 매크로를 전개해야 하므로 수동이지만 템플릿은 호출만 하면 컴파일러가 알아서 함수를 만드는 자동식이므로 매크로 함수보다는 역시 한수 위이다.

그래도 MakeSwap 매크로 함수는 그럴 듯해 보이기는 하는데 함수는 궁한대로 이 방법을 쓸 수도 있다. 그러나 같은 방법으로 클래스를 정의하는 매크로 함수는 만들 수 없다. 왜냐하면 함수는 이름이 같아도 타입이 다르면 오버로딩할 수 있지만 클래스는 오버로딩이 안되기 때문이다. ## 연산자를 쓰면 가능은 하겠지만 타입에 따라 클래스의 이름이 매번 달라지므로 쓰기에 불편하다.

흔하지는 않지만 템플릿 인수 목록에서 두 개 이상의 타입을 전달받을 수도 있다. 함수의 형식 인수 개수에 제한이 없듯이 함수 본체에서 변화가 생길만한 타입이 둘 이상이라면 함수 템플릿도 여러 개의 인수를 가질 수 있다. 이때는 원하는만큼 typename을 반복하되 각 타입의 이름은 구분할 수 있도록 다르게 작성해야 한다.

 

template <typename T1, typename T2>

 

당연한 얘기가 되겠지만 함수 템플릿 정의는 함수 호출부보다 먼저 와야 한다. 함수 템플릿 정의문에 의해 컴파일러는 임의의 타입 T의 값을 교환하는 함수의 모양을 Swap이라는 이름으로 기억할 것이다. 만약 main을 더 앞쪽에 두고 싶다면 순서를 바꿀 수 있되 템플릿에 대한 원형을 호출부의 앞쪽에 미리 선언해야 한다. 템플릿 함수의 원형은 template 키워드부터 시작해서 템플릿 함수의 선두를 그대로 가져간 후 세미콜론만 붙이면 만들 수 있다.

 

template <typename T>

void Swap(T &a, T &b);

 

일반 함수와 원형을 만드는 방법은 동일한데 원형 선언이 두 줄에 걸친다는 점에서 다소 어색해 보이기는 한다. 물론 한 줄에 붙여써도 별 이상은 없다. 이 함수 템플릿으로부터 실제 함수가 어떻게 만들어지는지는 다음 항에서 연구해 보자.