C/C++은 어떤 명칭에 대해 선언과 정의의 구분이 명확한 축에 드는 언어이다. 정의는 선언도 같이 포함하지만 그 역은 성립하지 않는다. 전자는 심판의 선고이고, 후자는 집행이라고 봐도 되겠다.
(1) 함수: 실행되는 코드를 담고 있기 때문에 {}에 둘러싸여 정의된 몸체의 존재감이 압도적인 물건이다. 또한 함수의 선언부는 자신의 프로토타입(인자의 개수와 타입, 리턴값의 타입)을 나타내기도 한다. 그렇기 때문에 얘는 소형 인라인 형태가 아닌 이상, 선언과 정의의 구분이 가장 명확하다.
(2) 자료형: 구조체나 클래스는 함수보다야 선언 따로 정의 따로일 일이 훨씬 드물다. 하지만 헤더에서는 포인터 형태만 사용하는데 쓸데없는 #include 의존성을 또 만들지 않기 위해 class Foo; 같은 불완전한 타입을 선언만 하는 게 가능은 하다. 마치 함수 선언처럼 말이다.
선언만 존재하는 불완전한 타입은 sizeof 연산자를 적용할 수 없으며, 포인터형의 경우 *나 ->로 역참조해서 사용할 수도 없다.
(3) static 멤버/전역 변수: 변수는 선언하는 것 자체 말고 딱히 {}로 둘러싸인 세부 정의가 존재하지 않는다. 생성자 인자라든가 초기화 값(initializer)이 쓰이긴 하지만 그건 definition, body와는 성격이 완전히 다른 정보이니 말이다.
다만, 지역 변수 말고 클래스의 static 멤버에 대해서는 static int bar와 int Foo::bar 같은 선언/정의 구분이 존재한다. 그리고 전역 변수도 extern이라고 선언된 놈은 정의가 아닌 선언 껍데기일 뿐이다. (실제 definition은 다른 translation unit에 존재한다는..)
사실, global scope에서 함수의 선언도 앞에 extern이 생략된 것이나 마찬가지이다. 지역 변수의 선언들이 모두 구 용법의 auto가 생략된 형태인 것처럼 말이다.
함수건 변수건 선언은 여러 군데에서 반복해서 할 수 있지만 몸체 정의는 딱 한 군데에만 존재한다. 이는 마치 분향소와 빈소의 관계와도 비슷해 보인다.
이런 선언부에서는 배열의 경우 그 구체적인 크기를 생략할 수 있다. * 대신 []을 써서 얘는 정확한 크기는 모르지만 어쨌든 포인터가 아닌 배열이라고 막연하게 선언할 수 있다는 것이다. 그리고 const 변수는 초기화 값이 선택이 아니라 필수인데, 이 역시 선언 단계에서 생략될 수 있다.
1. 함수와 구조체: 상호 참조를 위한 불완전한 전방 선언
(1) 함수나 (2) 구조체/클래스는 상호 참조를 할 수 있다. A라는 함수에서 B를 호출하고, B도 A를 호출할 수 있다. 또한, X라는 구조체에서 Y라는 구조체의 포인터를 멤버로 갖는데, Y도 내부적으로 X의 포인터를 갖고 있을 수 있다.
요즘 프로그래밍 언어들은 구조적으로 같은 소스 코드를 두 번 읽어서 파싱하게 돼 있기 때문에 한 함수에서 나중에 등장하는 다른 함수를 아무 제약 없이 참조할 수 있다. C++도 그런 요소가 있기 때문에 한 클래스의 인라인 멤버 함수에서 클래스 몸체의 뒷부분에 선언된 명칭에 곧장 접근할 수 있다. 즉, 다음과 같은 코드는 컴파일 된다.
class Foo {
public:
void func1() {
func2();
}
void func2() {
func1();
}
};
하지만 global scope에서 이런 코드는 적어도 C++ 문법에서는 허용되지 않는다.
void Global_Func1() {
Global_Func2();
}
void Global_Func2() {
Global_Func1();
}
맨 앞줄에 void Global_Func2(); 이라고 Global_Func2라는 명칭이 껍데기만이라도 forward(전방) 선언돼 있어야 한다. 파스칼 언어에는 이런 용도로 아예 forward라는 지정자 키워드가 있기도 하다.
매우 흥미로운 것은..
struct DATA1 {
DATA2* ptr;
};
struct DATA2 {
DATA1* ptr;
};
이렇게 구조체끼리 상호 참조를 하기 위해서는..
심지어 클래스 안의 구조체라 하더라도 앞에 struct DATA2는 반드시 미리 전방 선언이 돼 있어야 한다는 것이다. 클래스 안에 선언된 멤버 함수와는 취급이 다르다. 왜 그런 걸까? 멤버 함수의 몸체는 클래스 밖에 완전히 따로 정의될 수도 있지만 구조체의 몸체는 그럴 수 없다는 차이 때문인 듯하다.
원래 파스칼과 C는 옛날에 컴파일러의 구현 난이도와 동작 요구 사양을 낮추기 위해, 소스 코드를 한 번만 읽으면서 곧장 parsing이 가능하게 설계되기도 했다. 모든 명칭들은 사용되기 "전에" 정의까지는 아니어도 적어도 선언은 미리 돼 있어야 컴파일 가능하다. 아무 데서나 '정의'만 한번 해 놓으면 아무 데서나 그 명칭을 사용할 수 있는 그런 자유로운 언어가 아니라는 것이다.
함수와 전역 변수의 경우, 그 다음으로 몸체 정의를 찾아서 실제로 '연결'하는 건 잘 알다시피 링커가 할 일이다. 단지, 구조체/클래스는 몸체가 당장 컴파일 과정에서 그때 그때 쓰이기 때문에(멤버의 타입과 오프셋...) 링크가 아닌 컴파일 단계에서 실제 몸체를 알아야 한다는 차이가 있다.
불완전한 타입에 대해서 거기에 소속된 구조체/클래스를 불완전한 형태로 또 중첩 선언하는 것은 가능하지 않다.
class A;
class A::B;
A의 몸체를 모르는 상태에서 연쇄적으로 B를 저렇게 또 선언할 수는 없다는 것이다. 그걸 허용하는 건 C++을 동적 타입 언어급으로 만드는 너무 사악한(?) 짓이 될 것 같다. 특히 이미 자유도가 너무 높은 템플릿을 구현하는 것까지 생각했을 때 말이다.
실체가 없는 저런 자료형의 포인터를 무리하게 만들 바에야 아예 void* 포인터를 그때 그때 캐스팅해서 쓰고 말겠다. 아니면, 저런 식으로 다단계 scope 구분만 하는 게 목적이라면 클래스 대신 namespace라는 훌륭한 대체제가 있다.
2. 구조체: 전방 선언과 다중 상속 사이의 난감함
이렇게 몸체를 모르는 클래스를 불완전 전방 선언만 해서 쓰는 것은 일면 편리하지만.. C++이 제공하는 다른 기능 내지 이념과 충돌해서 난감한 상황을 만들 때도 있다.
즉, class X와 class Y라고 이름밖에 모르던 시절에는 X와 Y는 서로 완전히 남남이며, 포인터 형변환도 오프셋 보정 없이 단순무식한 C-style로만 하면 된다.
그런데 알고 보니 X와 Y가 다중 상속으로 얽힌 사이라면.. 몸체를 모르던 시절과 알고 난 뒤의 컴파일러의 코드 생성 방식이 서로 달라질 수밖에 없다. 특히 X 내지 Y의 멤버 함수를 가리키는 pointer-to-member 타입의 크기와 구현 방식도 달라지게 된다. X가 전방 선언만 돼서 아무런 단서가 없을 때가 제일 복잡하고 까다롭다.
Visual C++의 경우, 얘가 전방 선언만 됐지만 다중/가상 상속 같은 것 안 쓰는 제일 단순한 형태이기 때문에 pointer-to-member도 제일 단순한 형태로만 구현해도 된다고 단서를 제공하는 비표준 확장 키워드를 자체적으로 제공할 정도이다. 그만큼 C++의 스펙은 복잡 난해하고 패러다임이 서로 충돌하는 면모도 존재한다.
이렇듯, 명칭의 선언과 정의라는 간단한 개념을 고찰함으로써, C/C++ 이후의 언어들은 선배 언어의 복잡 난해함을 어떻게든 감추고 사용자와 컴파일러 개발자의 입장에서 다루기 편한 언어를 만들려고 어떤 개량을 했는지를 알 수 있다. 당장 Java만 해도 헤더/소스 구분 없이 한 클래스에서 각종 함수나 명칭을 수정하면 번거로운 재컴파일 없이도 그걸 다른 소스 코드에서 곧장 사용 가능하니 얼마나 편리한가 말이다.
3. 함수: extern "C"
앞서 살펴본 바와 같이 extern은 static library 형태이든 DLL/so 방식이든 무엇이건, 외부로 노출되는 전역 함수 및 변수 명칭을 선언하는 키워드이다. 그런데 기왕 대외 선언을 하는 김에 노출을 하는 방식도 옵션을 줘서 같이 지정할 수 있다.
모르는 사람에게는 굉장히 기괴해 보이는 문법인 extern "C"가 바로 그것이다. 이건 함수 명칭을 C++ 스타일로 decorate를 할지, 아니면 예전의 C 시절처럼 원래 이름을 변조 없이 그대로 선언할지를 지정한다.
C++에서 변조니 decorate니 해서 굳이 언어의 ABI 차원에서 호환을 깨뜨려야 하는 이유는.. C++에는 C와 달리 함수 인자를 기반으로 오버로딩이 존재하기 때문이다. 그러니 argument의 개수와 타입들에 대한 정보가 이름에 첨가돼야만 이 함수를 그 이름으로 유일하게 식별할 수 있다.
뭐, static 함수는 대외 노출이 아니니 한 번역 단위 안에서 함수 이름이야 어떻게 붙이건 전혀 상관없으며.. C++ 클래스 멤버 함수는 애초에 C언어에서 접근 불가능한 물건이이고 무조건 C++ 방식으로 decoration을 해야 한다. 그러니 extern "C" 옵션이 필요한 곳은 C와 C++이 모두 접근 가능한 일반 전역 함수 정도로 한정된다.
"C" 말고 쓸 수 있는 문자열 리터럴은 "C++".. 요 둘뿐이다. 그리고 "C++"은 디폴트 옵션이므로 signed만큼이나 잉여이고, 오늘날까지도 사실상 "C"만 쓰인다.
만들고 있는 라이브러리가 자기 제품 내부에서밖에 안 쓰이거나, 어차피 소스째로 통째로 배포되는 오픈소스여서 특정 컴파일러의 ABI에 종속되어도 아무 상관 없다면.. 함수를 C++ 형태로, 아니 C++ 클래스 라이브러리 형태로 선뜻 만들 수 있을 것이다.
그게 아니라면 외부 노출 함수 이름은 어느 언어에서나 쉽게 import 가능한 extern "C" 형태로 만드는 게 일반적이다. extern "C" 다음에는 이 구간에서 선언되는 명칭들을 모두 C 방식으로 노출하라고 중괄호 {}까지 줄 수 있으니 생소함과 기괴함이 더해진다.
이건 컴파일러의 구문 분석 방식을 변경하는 옵션이 아니다. {} 안의 코드는 C 문법으로만 해석하라는 말이 절대 아니다. extern "C" 방식으로 선언된 함수의 안에서도 템플릿, 지역 클래스 등 C++ 문법은 얼마든지 사용할 수 있고 타 C++ 객체를 참조할 수 있다. 단지 이 함수는 동일 명칭의 여러 오버로딩 버전을 만들어서 대외적으로 제공할 수 없을 뿐이다.
또한, 컴파일러의 최적화나 코드 생성 방식에 영향을 주지도 않는다. stdcall, pascal, cdecl 같은 calling convention이야 인자를 스택에다 올리는 순서 내지 스택 주소 복귀를 하는 주체(caller or callee)를 지정하는 것이니까 코드 생성 방식에 영향을 준다. 언어 문법 차원에서의 프로토타입이 동일하더라도 calling convention이 다른 함수끼리는 포인터가 서로 호환되지 않는다.
그에 반해 extern "C" 지정이 잘못되면 obj와 lib 사이에 공급된 명칭과 요청한 명칭이 일치하지 않아서 끽해야 링크 에러가 날 뿐이다. 개념이 이렇게 정리된다.
Posted by 사무엘