C/C++의 type string은 간단한 건 간단하지만 복잡한 건 한없이 복잡하다. C/C++ 프로그래밍 경력 10년이 넘는 본인조차 아직 그런 쪽에는 능숙하지 않으며, 좀 복잡한 type 선언을 해야 하면 옛날에 짜 놓은 코드를 복사해서 가져온다. -_-
복잡한 게 뭔지를 물으신다면, 이런 것을 말한다. 특정 함수의 포인터, 배열의 포인터를 되돌리는 함수의 포인터, 포인터의 참조자, C++ 멤버 포인터 등등... 생각만 해도 머리가 뱅뱅 돌지 않는지?
C/C++에서 뭔가 명칭을 선언하는 건 아래와 같이 일면 단순하다. 간단한 것, 상식적인 것부터 살펴보자.
type p;
이렇게 써 주면 p라는 명칭은 type이라는 타입으로 선언된다. p는 변수가 될 수도 있고 함수도 될 수도 있고 포인터나 배열 변수가 될 수도 있다. C++은 함수 내부의 아무 위치에서나 변수를 선언할 수 있으나, 함수 안에서 또 함수를 선언할 수는 없다. nested 함수라는 개념이 존재하지 않는 것이다.
type a, b, c;
처럼 콤마를 써서 여러 명칭을 동일 type으로 동시에 선언할 수도 있다.
type에는 int, float 같은 built-in type이 들어갈 수 있고, 사용자가 예전에 정의한 구조체· 공용체나 클래스가 들어갈 수도 있다.
C에서는 구조체· 공용체의 명칭 앞에 struct나 union 키워드를 생략할 수 없으며 생략하려면 typedef를 별도로 만들어야 하는 부조리가 있었으나, C++에서는 그런 한계가 없어졌다. type이 템플릿인 경우, 템플릿을 실제로 만들어 내는 argument도 < >에다 둘러싸서 넣어 줘야 하며, 타입 명칭이 다른 scope에 존재할 경우 :: 연산자도 써 줘야 한다. std::vector<int>처럼.
type 명칭에는 이 변수의 성격을 규정하는 modifier 키워드도 선택사항으로 들어갈 수 있다. 이런 예로는 const, volatile, register 같은 키워드가 있다.
type에 대한 설명은 여기까지로 하고, 그럼 p(명칭)에 대해 알아보자.
명칭은 한 번에 여러 개를 동시에 선언할 수 있고, 또 원한다면 p=1처럼 =을 써서 선언과 동시에 초기화도 가능하다. C++의 경우, 아예 ()을 써서 생성자 함수 호출을 바로 시키는 것도 가능하며 built-in type에 대해서도 생성자 함수 호출하듯 값을 초기화할 수 있다. 즉,
int *a=NULL, b=7; /* C style */
int *a(NULL), b(7); //C++ style
C에서는 위의 문장만 허용되는 반면 C++은 아래의 문장도 허용된다는 뜻이다.
자, 그럼 이제 진짜 복잡한 부분으로 들어가 보겠다.
C/C++의 문법이 판타지 같은 이유는, 분명 명칭의 type과 관련된 modifier들이 type 부분에 확실하게 구분되어 있는 게 아니라 name 부분으로 개별 적용되는 것도 있기 때문이다. 그렇기 때문에 C/C++은
int *a, b;
라고 선언하면 *라는 modifier는 a에만 적용되어 a만 int형에 대한 포인터가 되고 b는 일반 int가 되는 것이다. 그런데 D라는 언어는 그렇지 않아서 위와 같이 선언하면 a와 b의 타입이 모두 int*가 된다.
이런 식으로 개별적으로 적용되는 modifier로는 다음과 같은 것이 있다. 이런 것들이 막 섞이면 사람 머리 터지게 만든다. ^^;;
*p : p가 포인터임을 뜻한다. 변수의 왼쪽에 붙으며, 오른쪽에서 왼쪽으로 해석한다. *가 여러 개 붙으면 2중, 3중 포인터가 될 수 있다. (pointer to)
&p : C++에서 추가된 문법이며, p가 참조자임을 뜻한다. 쓰임이 포인터보다 훨씬 제한적이기 때문에 다중으로 붙을 수 없다. 용법은 *와 동일. (reference to)
int *&p;
라고 하면 우에서 좌로 & → * 순으로 해석되어 p는 포인터의 참조자가 된다(a reference to a pointer to integer). 반대로 참조자를 가리키는 포인터라든가 참조자를 또 가리키는 참조자라는 개념은 C++에 없기 때문에, &*나 && 같은 문법은 틀렸다. 포인터의 문법을 간소화하려고 만든 게 참조자인데 이는 상식적으로 당연한 얘기. 하지만 이중 포인터의 참조자인 **&은 있을 수 있다. 이 정도면 *와 &의 관계는 충분히 설명됐을 것이다.
다음,
p() : 어떤 명칭 바로 오른쪽에 ()가 붙었다면 이는 그 명칭이 함수임을 뜻한다. 쉽다.
p[n] : 그 명칭이 배열임을 뜻한다. 첨자가 들어있어야 하는 게 원칙이지만, 함수 argument라든가 일부 1차원적인 문맥에서는 첨자가 생략되어서 포인터와 별 차이 없는 용법이 되기도 한다. 영어로는 array of에 해당. []가 오른쪽 끝에 계속 붙으면 다차원 배열을 만들 수 있다.
그렇다면 명칭의 왼쪽에 포인터가, 오른쪽에 ()나 []가 다 붙어 있으면 어떻게 해석해야 할까?
일단 오른쪽 것부터 해석한다. 그 후 오른쪽 끝에 도달하면 왼쪽으로 간다. 그래서
int *a[10];
은 []이 먼저 해석되어 array of / pointer to / int가 되고, 따라서 ‘int *가 10개 있는 배열’이 된다.
이 순서를 바꾸기 위해서 또 괄호가 사용된다. 함수를 뜻하는 ()와는 쓰이는 문맥이 다르며, 의미도 다르다. 이걸 아는 게 중요하다.
int (*a)[10];
은 *이 먼저 해석된 후 오른쪽의 배열로 넘어가서 pointer to array[10] of int가 되고, 따라서 배열의 포인터가 된다. 사실, C/C++의 type string은 일종의 영어 어순을 따르고 있는 셈이다. 이걸 알면 쉽다. 꼭 기억하자.
int func(int x);
int (*funcptr)(int x) = func;
명칭 다음에 곧바로 ()가 나오면 함수 선언이 되나, 이름이 괄호로 둘러싸여서 *가 먼저 해석되므로 funcptr은 pointer to function, 즉 함수의 포인터가 되고, 자신과 prototype이 완전히 같은 func라는 함수를 가리킬 수 있게 되는 것이다.
닫는 괄호를 만나면 아직 해석되지 않았던 왼쪽으로 이동하고, 그러다가 여는 괄호를 만나면 다시 닫는 괄호 바깥의 오른쪽으로 가면서 완전히 바깥에 도달할 때까지 이 과정을 반복하면 된다.
따라서 명칭 뒤에 붙는 (), *, [] 같은 게 아무리 복잡하더라도, 명칭의 좌우에 가장 가까이 붙어 있는 놈이 뭔지만 보면, 얘가 포인터인지 함수인지 배열인지 정도는 바로 알 수 있다.
double ( *varr( double (*)[3] ) )[3];
위는 배열의 포인터와 함수의 포인터가 모두 동원된 예이다. 슬슬 머리가 아파질 것이다. varr의 좌우로 *와 ()가 있는데, 이때 오른쪽으로 먼저 간다. 그래서 varr은 함수가 되고 왼쪽의 *는 함수의 리턴값과 관계가 있게 된다. 그렇다. 이놈은 double 형 배열의 포인터를 인자로 받는 함수인데, 이 함수의 리턴값 역시 double 형 배열의 포인터라는 뜻이다.
double (* (*pfnFunc)( double (*)[3] ) )[3] = varr;
그리고 저 varr을 가리키는 함수의 포인터는.. varr만 (*pfnFunc)라고 또 감싸 주면 만들 수 있다. ^^;; 포인터를 되돌리는 함수의 포인터인 것이다.
int *(*(*fp1)(int))[10];
굉장히 변태-_-스러운 예제인데, 별표를 맨 왼쪽에 있는 것부터 [1], [2], [3]으로 번호를 매기자면,
fp1은 int 형을 인자로 받고, 원소 개수가 10인 int 포인터[1]의 배열에 대한 포인터[2]를 되돌리는 함수의 포인터[3]이다.
pointer to *
function (int)
returning pointer to *
array [10] of int*
이제 진짜 궁극의 변태 같은 예를 들면,
char *(*(**foo[2][8])())[10];
array [2][8] of
pointer to **
function ()
returning pointer to *
array [10] of char*
다시 말해 char*가 10개 들어있는 배열의 포인터를 되돌리는 함수의 2중 포인터를 담고 있는 2차원 배열이라는 소리이다. ^^;;
그럼 마지막으로, 또 하나의 기괴한 C++ 문법을 소개하면서 글을 맺겠다. 그것은 바로 멤버 포인터라는 특이한 포인터이다.
class CMyObject {
public:
int x,y,z;
void foo() {}
void bar() {}
};
CMyObject obj;
int CMyObject::*pVal = &CMyObject::x;
void (CMyObject::*pFunc)() = &CMyObject::foo;
obj.*pVal = 10;
(obj.*pFunc)();
위의 코드에서 볼 수 있듯 pVal은 int형인 x, y, z중 한 멤버 변수를 가리킬 수 있고, pFunc는 자신과 prototype이 같은 foo()와 bar() 중 하나를 가리킬 수 있다.
일반적인 C++ 클래스의 non-static 멤버들은 멤버 포인터로 하여금 자신을 가리키게 할 때 "&클래스::멤버"와 같은 식으로 주소를 얻을 수 있다. 이때 어느 토큰 하나도 생략할 수 없다. 심지어 자기 클래스 멤버 함수 내부에서라도 자기 클래스 이름을 반드시 명시해야 한다.
멤버 포인터를 나타내는 ::*은 ::와 *가 합쳐진 것이다. 그러나 멤버 포인터를 실제로 사용하는 연산자인 .* 또는 ->* 는 완전히 한 토큰으로, 사이를 띄울 수 없다. 또한 멤버 포인터 함수를 선언하고 호출할 때는 반드시 괄호가 필요하다. 이걸 하지 않으면 오른쪽의 함수 호출 ()가 먼저 해석되어서 개체와 멤버 포인터가 먼저 연결되지 못하기 때문이라 한다.
마치 파스칼 언어에서 우선순위 처리의 특이점 때문에 (a=1) and (b>5)처럼 각 항을 괄호로 싸 줘야 하는 것과 비슷한 맥락이라 하겠다.
그나저나 C++은 :: . -> 이렇게 세 연산자가 모두 따로 존재하는 언어라는 게 특이하다. 자바나 C#은 . 하나가 이들 기능을 모두 수행한다.
Posted by 사무엘