지금은 C++11이라고 개명된 C++ 확장 규격인 C++0x에는 잘 알다시피 여러 참신한 프로그래밍 요소들이 추가되었다. 몇 가지 예를 들면, 상당한 타이핑 수고를 덜어 줄 걸로 예상되는 auto 리뉴얼, 숫자와 포인터 사이의 모호성을 해소한 nullptr, 그리고 숫자와 enum 사이의 모호성을 해소한 enum class가 있다.

그런데 이것 말고도 C++11에는 아주 심오하고도 재미있는 개념이 하나 또 추가되었다. 복사 생성자에 이은 이동 생성자, 그리고 이를 지원하기 위한 type modifier인 &&이다. R-value 참조자라고 불린다. 이 글에서는 이것이 왜 도입되었는지를 실질적인 코드를 예를 들면서 설명하겠다.
다음은 생성자에서 주어진 문자열의 복사본을 보관하는 일만 하는 아주 간단한 클래스이다.

//typedef const char*  PCSTR;
class MyObject {
    PCSTR dat;
public:
    MyObject(PCSTR s): dat(strdup(s)) {}
    ~MyObject() { free( const_cast<PSTR>(dat) ); }
    operator PCSTR() const { return dat; }
};

C++은 언어 차원에서 포인터를 자동으로 관리해 주는 게 전혀 없다. 그렇기 때문에 저렇게만 달랑 짜 놓은 클래스는 함부로 값을 대입하거나 함수 호출 때 개체를 reference가 아닌 value로 넘겨 줬다간, 동일 메모리의 다중 해제 때문에 프로그램이 jot망하게 된다. C++ 프로그래머라면 누구라도 위의 코드의 문제를 즉시 알 수 있을 것이다.

그렇기 때문에, 포인터처럼 외부 자원을 따로 가리키는 클래스는 복사 생성자와 대입 연산자를 별도로 구현해 줘야 한다. 구현을 안 할 거면 하다못해 해당 함수들을 빈 껍데기만 private 형태로 정의해서 접근이 되지 않게 해 놓기라도 해야 안전하다.

MyObject(const MyObject& s): dat(strdup(s))
{
    puts("복사 생성자");
}
MyObject& operator=(const MyObject& s)
{
    free(dat); dat=strdup(s.dat); puts("복사 대입");
    return *this;
}

자, 그럼 이를 이용해 그 이름도 유명한 Swap 루틴을 구현해서 복사 생성자와 대입 연산자를 테스트해 보자.

template<typename T>
void Swap(T& a, T& b) { T c(a); a=b; b=c; }

int main()
{
    MyObject a("새마을호"), b("무궁화호");
    printf("%s(%X) %s(%X)\n", (PCSTR)a,(PCSTR)a, (PCSTR)b,(PCSTR)b);
    Swap(a,b);
    printf("%s(%X) %s(%X)\n", (PCSTR)a,(PCSTR)a, (PCSTR)b,(PCSTR)b);
    return 0;
}

프로그램의 실행 결과는 다음과 같은 식으로 나올 것이다.

새마을호(181380) 무궁화호(181390)
복사 생성자
복사 대입
복사 대입
무궁화호(1813B8) 새마을호(1813D0)

복사 생성자와 대입 연산자 덕분에 메모리 관리는 옳게 되었기 때문에, 이제 프로그램이 뻗는다거나 하지는 않는다.
그러나 이 방법은 비효율적인 면모가 있다. 개체의 값을 맞바꾸기 위한 세 번의 연산 작업 동안, 당연한 말이지만 메모리 할당과 해제, 그리고 문자열의 복사가 매번 발생했다. 그래서 비록 문자열 값은 동일하지만 그 문자열이 담긴 메모리 주소는 a와 b 모두 예전과는 완전히 다른 곳으로 바뀌었음을 알 수 있다.

이때 R-value 참조자를 쓰면, 이 클래스에 대해서 Swap 연산이 메모리를 일일이 재할당· 복사· 해제하는 게 아니라 a와 b가 가리키는 문자열 메모리 주소만 간편하게 맞바꾸도록 하는 언어적인 근간을 마련할 수 있다. 기존 참조자는 &로 표현하고, 이와 구분하기 위해 R-value 참조자는 &&로 표현된다. 참조자(&)는 포인터(*)와는 달리 다중 참조자(참조자의 참조자) 같은 개념이 없기 때문에, &&을 이런 식으로 활용해도 문법에 모호성이 생기지 않는다.

& 대신 &&를 이용해서 자신과 동일한 타입의 개체를 받아들이는 생성자와 대입 연산자를 추가로 정의할 수 있다. 이 경우, 이들 함수는 복사가 아닌 이동 생성자와 이동 대입 함수가 된다. 아래의 예를 보라.

MyObject(MyObject&& s)
{
    dat=s.dat, s.dat=NULL; puts("이동 생성자");
}
MyObject& operator=(MyObject&& s)
{
    //주의: 실제 코드라면 자기 자신에다가 대입하는 건 아닌지 체크하는
    //로직이 추가되어야 한다. if(&s!=this)일 때만 수행하도록.
    free(dat); dat=s.dat, s.dat=NULL; puts("이동 대입");
    return *this;
}

복사 버전과는 달리, strdup 함수 대신 그냥 포인터 대입을 썼음을 알 수 있다. 이것이 핵심이다.
그러면 s가 가리키던 메모리 영역이 내 것이 된다. 그 뒤 s가 가리키던 메모리는 NULL로 없애 줘야 한다. free 함수는 그 스펙상 자체적으로 NULL 체크를 하기 때문에, 소멸자 함수는 그대로 놔 둬도 된다.

즉, 이동 생성자와 이동 대입은 s의 값을 내 것으로 설정하긴 하나, 그 과정에서 필요하다면 s의 내부 상태를 건드려서 바꿔 놓을 수 있다. 그렇기 때문에 복사 생성자/대입과는 달리 s가 const 타입이 아니다.

이것만 선언해 줬다고 해서 Swap 함수의 동작 방식이 이동 연산으로 곧장 바뀌는 건 물론 아니다. 그랬다간 s의 상태가 바뀌고 프로그램 로직이 달라져 버리기 때문에, 컴파일러가 섣불리 동작을 바꿀 수 없다. 그렇기 때문에 Swap 함수의 코드도 move-aware하게 살짝 고쳐야 한다.

template<typename T>
void Swap(T& a, T& b)
{
    T c(static_cast<T&&>(a)); a=static_cast<T&&>(b); b=static_cast<T&&>(c);
}

즉, 개체를 생성하고 대입하는 곳에서, 가져오는 개체를 가능한 한 move로 취급하라고 명시적인 형변환을 해 줘야 한다. 이렇게 해 주고 나면 드디어 우리의 목표가 이뤄진다!

새마을호(181380) 무궁화호(181390)
이동 생성자
이동 대입
이동 대입
무궁화호(181390) 새마을호(181380)

물론, 저런 형변환 연산이 보기 싫은 사람은 <vector>에 정의되어 있는 std::move 함수로 이동 대입을 해도 되며, 보통 R-value 참조자를 설명해 놓은 인터넷 사이트들도 그 함수를 곧장 소개하고 있다. 하지만 그 함수의 언어적인 근거가 바로 이 문법이라는 건 알 필요가 있다.

생성이나 대입에서 R-value 참조자를 받지 않고 기존의 L-value 참조자만 받는 클래스에 대해서는, 이동 대입이나 생성도 자동으로 옛날처럼 복사 대입이나 생성 방식으로 행해진다.
다시 말해, Swap 함수의 로직을 저렇게 고치더라도 R-value 참조자가 구현되어 있지 않은 기존 타입들에 대한 동작은 전혀 바뀌지 않으며 컴파일 에러 같은 게 나지도 않는다. 그러니 호환성 걱정은 할 필요가 없다.

그리고 이미 눈치챈 분도 있겠지만, MFC의 CString처럼 자기가 가리키는 메모리에 대해서 자체적으로 reference counting을 하고 copy-on-modify 같은 테크닉을 구현해 놓았기 때문에, 어차피 복사 생성이나 call by value 때 무식한 오버헤드가 발생하지 않는 클래스라면, 구태여 이동 생성자나 이동 대입 연산자를 또 구현할 필요가 없다. 이동 생성/대입은 언제까지나 기존의 복사 생성/대입을 보조하기 위해서 도입되었기 때문이다.

특히 std::vector 같은 배열 컨테이너 클래스에다가 덩치 큰 개체를 집어넣거나 뺄 때 복사 생성자가 쓸데없는 오버헤드를 발생시키는 걸 막는 게 이 문법의 주 목적이다. 그렇기 때문에 딱히 smart한 복사 메커니즘을 갖추고 있지 않은 클래스를 STL 컨테이너에다 집어넣고 쓰는 C++ 코드라면, 적절한 이동 생성자와 대입 연산자를 구현해 주고 R-value 참조자를 지원하는 최신 C++ 컴파일러로 다시 빌드를 하는 것만으로도 성능 향상을 경험할 수 있다.

예전에는 배열 컨테이너 클래스들이 원소들의 일괄 삽입이나 삭제를 위해 무식한 memmove 함수를 내부적으로 쓰는 게 불가피했는데 이 역할을 이동 대입이 어느 정도 대체도 할 수 있게 됐다.
&&을 DLL symbol로 표기하기 위한 새로운 C++ type decoration도 별도로 물론 있다.

그런데 의문이 생긴다. &&의 이름이 왜 R-value 참조자인 것일까?
이 참조자는 참조자이긴 하지만, 오리지널 참조자처럼 L-value가 아니라 R-value를 취급하라고 만들어졌기 때문이다. L-value, R-value란 무엇인가? 대입문에서 좌변과 우변을 뜻한다. L-value란 값을 갖고 있으면서 동시에 대입의 대상이 될 수 있는 변수를 가리키며, R-value는 값을 표현할 수만 있지 그 자신이 다른 값으로 바뀔 수는 없는 상수, 혹은 임시 개체를 가리킨다고 보면 얼추 맞다.

아래의 코드에서 볼 수 있듯 기존 L-value 참조자는 dereference된 포인터와 같은 역할을 한다.

int& GetValue() { … }
GetValue() = 100;

int *GetValue() { … }
*GetValue() = 100;

그렇기 때문에 아래와 같은 특성도 존재한다.

void GetValue2(int& x) { x=… }

int a;
GetValue2(a); //a는 L-value이므로 OK
GetValue2(500); //에러. 당연한 귀결임

L-value 참조자가 상수값 내지 임시 생성 개체 같은 R-value를 함수의 인자로 받아들이려면, 해당 참조자는 const로 선언되어서 값의 변경이 함수 내부에서 발생하지 않는다는 보장이 되어야 한다. int&가 아니라 const int&로 말이다.

그런데 R-value 참조자는 const 속성 없이도 임시 개체나 상수값을 받아들이며, 그걸 뒤끝 없이 자유롭게 고칠 수 있다. 위의 GetValue2 함수가 int&&로 선언되었다면, 반대로 a를 전달한 게 에러가 나고 500을 전달한 건 괜찮다. a를 전달하려면 static_cast<int&&>(a)로 형변환을 해 줘야 한다. 그러면 마치 int&인 것처럼 실행되긴 한다.

R-value 참조자로 돌아온 함수의 리턴값은 말 그대로 R-value이기 때문에 대입 가능하지 않다. 그렇기 때문에 아래의 코드는 에러를 일으킨다. (R-value 참조자의 리턴값은 당연히 그 역시 R-value로 왔을 때에만 의미가 있을 것이다.)

int&& GetValue3() { … }
GetValue3() = 100; //에러

이런 R-value 참조자라는 괴상망측한 개념은 왜 도입된 것일까? 그리고 이게 앞서 이 글에서 언급한 이동 생성자/대입 연산하고는 도대체 무슨 관련이 있는 것일까?

R-value 참조자의 형태로 함수 인자로 넘어온 개체는 그 함수의 실행이 끝난 뒤엔 어차피 소멸되고 없어질 것이기 때문에 내부가 바뀌어도 상관없다. 즉, 이 참조자는 태생적으로 const 속성과는 어울리지 않는다. 오히려 const-ness가 보장되지 않아도 되는 제한적인 문맥에서, 쓸데없는 복사를 할 필요 없이 꼼수를 좀 더 합법적으로 구사할 수 있게 위해 이런 문법이 추가되었다고 보는 게 타당하다.

마지막으로 R-value 참조자가 유용하게 쓰이는 용도를 딱 하나만 더 소개하고 글을 맺겠다.
윈도우 API+MFC 기준으로, RECT 구조체를 받아서 이 값을 적당히 변형한 뒤에 이를 토대로 후처리를 하는 함수를 생각해 보자.

void Foo(const RECT& rc)
{
    RECT rc2 = rc; //rc는 const이기 때문에 복사본을 만들어야 함

    ::OffsetRect(&rc2, x,y); //변형
    ::DrawText(hDC, strMsg, -1, &rc2, 0);
}

void Foo(RECT&& rc)
{
    ::OffsetRect(&rc, x,y); //복사본 만들 필요 없이 rc를 곧바로 고쳐서 사용하면 됨
    ::DrawText(hDC, strMsg, -1, &rc, 0);
}

CRect r(100, 200, 400, 350);
Foo(r); //const RECT& 버전이 호출됨
Foo( CRect(0,0, 400,300) ); //임시 개체임. RECT&& 버전이 호출됨

RECT를 value로 전달했다면 당연히 복사가 일어나고, const reference로 전달했다면 역시 복사가 행해져야 한다. 그러나 애초에 함수에 전달되는 인자가 임시 개체였다면, 임시 개체에 대한 복사본을 또 만들 필요 없이 그냥 그 임시 개체를 바로 고쳐 쓰면 된다. 위의 코드의 의미가 이해가 되시겠는가?

R-value 참조자라는 게 왜 필요한지, 그리고 이게 왜 이동 생성/대입과 관계가 있는지 본인은 이해하는 데 굉장히 긴 시간이 걸렸다. 인터넷에 올라와 있는 다른 설명만 읽어서는 도통 이해가 되지 않아서 직접 코드를 돌리고 컴파일을 해 본 뒤에야 개념을 깨우쳤는데, 알고 나니 정말 이런 걸 생각해 낸 사람들은 천재라는 생각이 든다.;; C++은 참으로 복잡미묘한 언어이다.

Posted by 사무엘

2012/05/16 08:41 2012/05/16 08:41
,
Response
No Trackback , 15 Comments
RSS :
http://moogi.new21.org/tc/rss/response/683

예전에 본인이 글로 쓴 적도 있고, 상식 차원에서 이미 아시는 분도 있겠지만..
프로그래밍 언어마다 문자열을 다루는 방식엔 차이가 존재한다.
C/C++은 null-terminated 문자열이라는 단순하고 독특한 체계를 사용하는 반면, 다른 언어들은 그렇지 않다.
그렇기 때문에, 문자열 상수가 실행 파일 내부에 어떤 형태로 박혀 있는지를 추적하면, 이 프로그램이 무슨 언어로 만들어졌겠는지 추측이 어느 정도 가능하다.

과거의 도스 시절에는 볼랜드 사에서 개발한 터보 시리즈의 컴파일러가 인기가 많았다. C/C++과 파스칼이 기억에 남는다. 이 볼랜드 제품은 당시 타사의 컴파일러가 제공하지 않던 두 가지 독자적인 기능이 있었다. 하나는 깔끔하게 잘 만들어진 IDE(에디터)였고, 다른 하나는 BGI(볼랜드 그래픽 인터페이스)라고 일컬어지는 그래픽 API였다.

한 IDE에서 프로그램을 바로 빌드-실행-디버그할 수 있으니 프로그램 개발 생산성이 뛰어나고 굉장히 편리하다. 이에 덧붙여, 그래픽은 그렇잖아도 printf 같은 표준화된 API 규격이 전무해서 ‘싸제’ 라이브러리에 의존할 수밖에 없던 영역인데, 자체 개발 라이브러리가 있다 보니 볼랜드의 컴파일러는 폭발적인 인기를 모을 수밖에 없었다.
bgidemo라고 유명한 그래픽 API 예제 프로그램도 있었는데 기억하는 분이 있으려나 모르겠다. QBasic용 예제 프로그램인 nibbles, gorilla 게임과 비슷한 시기에 만들어진 그 시절 추억이다.

사용자 삽입 이미지

아래의 스크린샷은 이 BGI 라이브러리를 사용해서(=링크해서) 만들어진 어느 EXE 파일 내부를 들여다본 모습이다. 그래픽 라이브러리이다 보니 내부적으로 출력하는 에러 메시지 문자열, 가령 No error, (BGI) graphics not installed, 심지어 Out of memory in flood fill 같은 친숙한 문자열이 내장되어 있음을 알 수 있다. 그런데 동일한 문자열들 사이에 한 놈은 ▲, →, ← 같은 이상한 기호가 듬성듬성 끼어 들어가 있다. 왜 그럴까?

사용자 삽입 이미지

사용자 삽입 이미지

기호가 없는 프로그램은 C언어(=터보 C)로 만들어진 프로그램이다. 왼쪽의 16진수값을 보면 알겠지만, 이들은 모든 문자열들이 그냥 0번 문자로 구분되어 있다.
그러나 기호가 있는 프로그램은 파스칼로 만들어진 프로그램이다. ▲, →, ←은 다음에 뒤따르는 문자열의 길이를 의미한다. 예를 들어 “▲Graphics hardware not detected”를 보면 ▲의 코드 번호는 0x1E, 즉 30인데 그 에러 메시지의 길이는 30바이트임을 알 수 있다. 얘네는 반대로 문자열들 사이에 0번 문자가 전혀 존재하지 않는다.

실제로 C/C++ 말고 String이 built-in type으로 존재하는 언어들은 이렇게 글자 수를 따로 저장해 놓는 방식으로 문자열들을 관리한다. 베이직으로 만들어진 프로그램도 QuickBasic이든 PowerBasic이든 문자열 상수들을 들여다보면 비슷한 결과를 얻을 수 있다. 그래서 이런 언어는 문자열의 길이를 구하는 함수의 시간 복잡도가 O(1)인 반면, C언어만 strlen의 시간 복잡도는 O(n)이다.

베이직 언어들은 문자열의 길이가 16비트 정수로 저장되던 반면, 터보 파스칼은 문자열 길이를 달랑 8비트 크기로 저장하여, 문자열의 길이가 256자를 넘을 수 없다는 한계가 존재했다. 흠;;

파스칼로 만든 프로그램을 들여다보면 Runtime error 같은 문자열도 존재한다. 이 역시 C/C++로 만들어진 프로그램에서는 디버그 빌드가 아닌 이상 있을 수 없는 개념이다. C/C++은 배열 첨자 범위의 검사조차도 안 할 정도로 런타임 에러라는 개념 자체가 존재하지 않는-_- 언어이기 때문이다. 그저 컴퓨터 다운(도스 시절)이 아니면 segmentation/page fault(요즘 같은 보호 모드 운영체제에서)-_-만이 존재할 뿐. -_-;;

그 반면, %d, %s이라든가 Null pointer assignment 같은 문자열이 있다면 그건 99.9% C 라이브러리가 들어갔다는 뜻이고 그 프로그램은 C/C++로 작성되었다고 유추할 수 있다.

덧붙이는 말

1. 볼랜드는 BGI 라이브러리만큼이나 텍스트 모드용 GUI? TUI? 툴킷으로 Turbo Vision이라는 라이브러리를 개발한 것으로도 유명했다. MS가 도스용 비주얼 베이직을 잠시나마 개발했다면 볼랜드에는 이런 게 있었던 셈. 당장 터보 C++과 파스칼의 IDE부터가 이를 사용해서 개발되기 시작했다. 비록 C/C++과 파스칼에서 모두 지원되긴 했지만 이 언어의 주 개발 및 지원 언어는 파스칼이었지 싶다. MS가 베이직을 좋아한다면, 볼랜드는 전통적으로 파스칼을 더 좋아하는 회사였다. (그러니까 훗날 델파이까지 만들었지)

지금은 세월이 세월이다 보니 소스가 완전히 풀려서 이이 프로젝트는 오픈소스 진영에서 관리되고 있다. 내 기억이 맞다면 DJGPP의 IDE인 Rhide가 이 Turbo Vision의 오픈소스 버전으로 개발되었다.
그리고 우리나라에서 PC 경진대회가 정보 올림피아드로 최초로 바뀌었던 1996년(13회), 대회의 채점 프로그램이 Turbo Vision 기반으로 개발되어 있던 걸 본인은 분명히 봤다.

2. 오늘날 윈도우용 네이티브 EXE/DLL이 만들어지는 출처는, 내 감으로는 비주얼 C++이 적게 잡아도 70% 이상, 그 뒤에 소수의 오픈소스 프로젝트용으로 gcc, 그리고 끝으로 델파이 정도가 고작인 것 같다. 볼랜드는 그 후로 다른 회사에 인수되면서 이름도 여러 번 바뀌고(InPrise, CodeGear, Embarcadero 등...;;) 우여곡절을 많이 겪었는데 걔네 입장에서는 옛날의 영광이 그리울 법도 할 것 같다.

3. BGI 라이브러리와 파워베이직--얘 역시 전신이 볼랜드 사의 터보 베이직이긴 했지만--의 그래픽 라이브러리는 이상하게도 VGA mode 13h를 지원하지 않아서 개인적으로 아쉬웠었다. (퀵베이직은 지원했는데...) 해상도가 너무 낮아서 한글· 한자 같은 문자를 찍는 데는 부적격이었지만 256색 덕분에 게임 만들 때는 필수이던 그래픽 모드이다. 그게 지원됐으면 그 당시 게임 만들기가 훨씬 더 수월했을 텐데 말이다.

Posted by 사무엘

2011/07/15 08:38 2011/07/15 08:38
, , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/540

C/C++로 프로그램을 개발하는 과정에서 아주 난감해지는 경우 중 하나는, 바로 Debug 빌드와 Release 빌드의 실행 결과가 서로 다를 때이다. 개발 중이던 Debug 빌드 스냅샷에서는 잘만 돌아가는 프로그램이 정작 최적화된 Release 빌드에서는 이따금씩(항상도 아니고!) 에러가 난다면?

이런 버그는 문제를 찾아내려고 정작 디버거를 붙여서 실행할 때는 재연되지 않는 경우가 태반이어서 프로그래머를 더욱 애먹인다. 특히 복잡한 멀티스레드와 관련된 버그라면 그저 묵념뿐..;; 하지만 그런 특수한 경우가 아니라면, Debug와 Release의 실행 결과가 다른 이유는 본인의 경험상 거의 대부분이 초기화되지 않은 변수 때문이었다.

비주얼 C++은 Debug 빌드에서는 초기화되지 않은(공간 확보만 해 놓고 프로그램이 아직 건드리지는 않은) 메모리의 영역을 티가 나는 값으로 미리 표시도 해 놓고 아주 특수하게 취급해 준다. 메모리를 할당해도 좌우에 여분을 두고 좀 넉넉하게 할당하며, 때로는 그 넉넉한 여분 공간의 값이 바뀐 것을 감지하여(바뀌어서는 안 되는데) 배열 첨자 초과 같은 에러를 알려 주기도 한다. 프로그래머의 입장에서야 이건 꽤 유용한 기능이다.

그러나 Release 빌드에는 이런 거추장스러운 작업이 물론 전혀 없다. 그러니 메모리 범위를 초과한다거나, 읽어서는 안 되는 엉뚱한 주소의 메모리로부터 값을 읽거나, 올바른 영역이더라도 초기화되지 않은 쓰레기 값을 얻었을 때의 결과는 두 빌드가 서로 극과 극으로 달라질 수밖에 없다.

이렇게, 빌드 configuration에 따라 동작이 달라지는 코드는 두말 할 나위도 없이 결함이 들어있는 faulty 코드이다. 이런 코드에서 문제의 원인을 찾는 건 극도로 어려운 일이다. 서울에서 김 서방 찾기, 모래사장에서 바늘 찾기, 사격장에서 흘린 탄피 찾기가 따로 없다. ㅜㅜ 자기가 짠 코드에서 결함을 찾는 것도 어려워 죽겠는데 하물며 회사 같은 데서 남이 짠 faulty 코드를 인수인계 받았다면... -_-;;;

(본인이 다니던 모 병특 회사에서 본인의 직속 상사는 이렇게 말했다. “그런 코드를 짜는 건 프로그래밍을 하는 게 아니라 똥을 싸는 거다.” 공감한다. -_-)

C/C++은 물론 간단한 지역 변수에 대해서야 ‘이 변수를 초기화하지 않고 사용했습니다’ 같은 지적을 컴파일 시점에서 해 준다. 그러나 복잡한 포인터나 배열로 가면 일일이 그 용법이 올바른지 컴파일 시점에서 판단하지는 못한다. 그저 프로그래머가 조심해서 코드를 작성하는 수밖에 없다.

이와 관련된 본인의 경험을 소개하겠다.
꽤 옛날에 짜 놓은 비주얼 C++ MFC 기반 GUI 프로그램 소스의 내부에서, 핵심 알고리즘만 떼어내서 다른 콘솔 프로그램에다 붙여야 할 일이 있었다.
그 당시에는 나름 구조적으로 프로그램을 만든 것이지만, 지금 관점에서 모듈간의 cohesion은 여전히 개판오분전이었던지라 상당수의 코드를 리팩터링해야 했다.

그래서 코드를 붙였는데, 원래의 GUI 프로그램에서는 잘 돌아가던 코드가 새로운 프로젝트에서는 얼마 못 가서 뻗어 버렸다. Debug 빌드와 Release 빌드의 실행 결과가 다른 건 두말 할 나위도 없거니와, 심지어 같은 Release 빌드도 F5 디버거를 붙여서 실행하면 별 탈이 없는데 그냥 실행하면 뻗었다! 이건 스레드 쓰는 프로그램도 아닌데! 이거야말로 제일 골치 아픈 경우가 아닐 수 없었다.

Debug 빌드는 Release 빌드보다 워낙 느리게 돌아가고, Release 빌드도 디버거를 붙였을 때와 그렇지 않았을 때 성능이 살짝 달라진다. 그러니 앞에서 언급했듯이 스레드 관련 race condition은 영향을 받을 수 있다. 하지만 그런 것도 아니라면? 의심스러운 배열은 무조건 다 0으로 초기화하고, 혹시 내가 리팩터링을 하면서 실수를 하지는 않았는지 몇 번이나 꼼꼼이 살펴봤지만 문제는 눈에 띄지 않았다.

별 수 있나. printf 로그를 곳곳에다 박아 넣어서 의심스러운 부분을 추적한 뒤 다행히 문제를 찾아냈다.
게임 같은 리얼타임 시스템에서는, 심지어 디버그 로그 찍는 코드만 추가해도 버그가 쏙 숨바꼭질을 해 버리는 막장 중의 막장 경우도 있다만 내 프로그램은 그런 정도는 아니어서리..;;

사실은 기존 GUI 프로그램에서 돌아가던 코드에서부터 문제가 있었다.
배열을 선언했는데, 0~1번 인덱스에 접근할 일이 없어서

ptrData = new char[100];
ptrData-=2;

같은 잔머리를 굴려 줬던 것이다. 요런 짓을 옛날에 Deap 자료구조를 구현할 때도 했던 것 같다.
그러니 이 포인터로는 0과 1번 인덱스를 건드리지 않아야 하는데...
그런데 그것이 실제로 일어났습니다. ㄲㄲㄲㄲㄲ

그 허용되지 않는 메모리의 상태가 GUI 프로그램과 콘솔 프로그램, 심지어 같은 프로그램도 Debug와 Release, 디버거 붙이냐 안 붙이냐 여부에 따라 싹 달라져서 나를 골탕먹였던 것이다. 예전에는 수 년째 아무 탈 없이 잘 돌아가던 코드가 말이다.
저런 간단하고 고전적인 배열 첨자 초과 문제가 이런 결과를 야기할 줄 누가 알았을까?

C/C++은 내가 짠 코드를 내가 완전히 책임질 수 있고 컴퓨터 관점에서의 성능· 능률· 최적화가 중요한 해커나 컴덕후에게는 가히 환상적인 언어이다. 이보다 더 좋을 수가 없다. 예전에 내가 비유했듯, 세벌식이 기계 능률과 인체 공학적인 특징을 잘 살린 것만큼이나 이 언어는 고급 언어의 특성과 기계적인 특성을 꽤-_- 잘 절충했다.

그러나 언어의 구조적으로 가능한 무질서도가 너무 높은 것도 사실. C/C++가 까이는 면모 자체가 크게 (1) 언어 자체의 복잡도 내지 결함 그리고 (2) unmanaged 환경이라는 여건 자체라는 두 갈래로 나뉘는 양상을 보인다. 오늘날의 소프트웨어 시스템에서 프로그래밍 언어는 모름지기 수십, 수백만 줄의 프로젝트에서 살인적인 복잡도를 제어 가능해야 하고, 작성한 코드의 최소한의 품질과 안전성이 보장되어야 하며, 또 무엇보다도 빨리빨리 빌드가 돼야 하는데 C/C++은 영 한계를 보이기도 한다.

뭐, 그래도 이미 C/C++로 작성된 코드가 너-_-무 많고 그것도 다들 중요한 저수준 계층에 있다 보니, 이 언어가 쉽게 없어지지는 않을 것이고 특히 C++은 몰라도 C는 절대 안 없어지리라.. ㅋㅋ 프로그래밍 언어의 라틴어급.

C/C++과는 전혀 다른 언어이다만, 과거엔 QuickBasic도 IDE에서 돌리는 프로그램과, 실제로 컴파일-링크를 한 EXE의 실행 모습이 대동소이하게 달라서 프로그래머를 애먹이기도 했다. 물론 이건 C/C++에서의 Debug/Release와는 다른 양상 때문에 차이가 나는 경우이다.
결론은, 프로그램 작성하다가도 틈틈이 Release 형태로 최종 결과물을 확인하는 게 필요하다. ^^

Posted by 사무엘

2011/06/22 08:23 2011/06/22 08:23
,
Response
No Trackback , 6 Comments
RSS :
http://moogi.new21.org/tc/rss/response/529

C/C++에는 ? : 라는 독특한 연산자가 있다. A ? B: C꼴로 표현되어 피연산자가 3개나 붙는 유일한 연산자이다.
이 연산자의 역할은 매우 단순하다. A가 참이면 연산자의 값은 B가 되고, 그렇지 않으면 C가 된다. 그래서 아예 if문의 역할을 간단히 대신할 수도 있으며, 콤마 연산자와 결합하면 어지간한 함수 호출마저도 한 연산식에다 박아 넣을 수 있다. 다만, 그게 너무 사악하다고 여겨졌는지-_-, C# 언어에는 콤마 연산자가 사라지고 콤마는 for 키워드 안에서만 제한적으로나 허용되지 싶다.

? : 는 &&, || 와 마찬가지로 C/C++에서 단축연산이 적용된다. A && B에서 A가 거짓이면 B는 실행이 전혀 되지 않고 전체 결과가 거짓이 되며, A || B에서 A가 참이면 B는 실행되지 않고 바로 전체 결과가 참이 된다. 그런 것처럼 ? :는 선택되지 않은 항에 대해서는 당연히 연산이 일어나지 않는다.

<날개셋> 한글 입력기는 짝퉁 C언어 문법 수식 해석기를 내장하고 있기 때문에, 이를 이용해 글쇠, 오토마타, 글자판 전환 글쇠 등에서 문자 입력 시스템의 자유도를 굉장히 높일 수 있다. 비록 튜링 완전한 수준은 못 돼도 말이다. 이때에도 ? : 연산자는 물론 매우 요긴하게 쓰인다.

? : 는 좌결합이 아니라 우결합이다. A ? B : C ? D : E는 (A?B:C) ? D : E가 아니라 A ? B : (C?D:E)로 결합한다. 그러므로 전자처럼 쓰려면 괄호를 넣어 줘야 한다.

? : 는 다른 연산 구문들을 포함하는 if문 대용처럼 쓰이는 만큼, 연산자의 우선순위가 상당히 낮다. 다른 평범한 연산자들이 다 결합한 뒤 나중에야 적용된다. 그게 합리적이다.
그러나 얘도 콤마와 대입 연산자보다는 순위가 높다. 그렇기 때문에 A = B ? C : D 라고 써 주면 알아서 A = (B?C:D)로 해석되어, A에는 B 조건의 충족 여부에 따라 C 아니면 D가 대입된다.

반대로, ? : 의 내부에 콤마 연산이나 대입 연산이 포함되어야 한다면 이들 연산은 무조건 괄호로 싸야 한다.

A ? (B=2): (C=5)
B에다가 괄호를 안 하면 = 가 ?와 :를 둘로 쪼개 버리는 효과가 나기 때문에 에러가 발생한다.
그리고 C에다가도 괄호를 생략할 수 없는데, 괄호를 안 하면 연산의 의미가 (A?(B=2):C)=5가 되어 버리기 때문이다. 우선순위의 특성상, =가 C항이 아니라 ? = 전체와 대응한다는 뜻 되겠다.

그리고 또 생각해 볼 것은, ? : 연산자의 값은 L-value가 될 수 있겠냐는 점이다. (대입 가능하겠냐)
<날개셋> 한글 입력기는 수식이 처음 도입된 3.0 이래로 지금까지 (조건 ? A:B)=100 과 같은 구문이 지원된 적은 없다. 그러나 이제 <날개셋> 6.0 이후의 다음 버전부터는 그게 가능해진다. 단, 2항과 3항 중 하나라도 변수에 연산자가 조금이라도 붙어서 A+2, -B 같은 형태가 되면 L-value 원칙이 깨지게 되는데, 그런 오류는 수식 입력 시점에서 프로그램이 자동으로 감지해 준다.

이게 지원되면 조건 ? (A=100): (B=100)보다야 구문을 더욱 간단하게 만들 수 있으니까 사용자의 입장에서 좋을 것이다. 더구나 콤마 연산자도 최후의 항의 변수 정보를 남겨 주기 때문에 (조건 ? (A=100,C): (B=50,D)) +=20 같은 복잡한 대입도 가능해진다. 저 식의 의미는 무엇일지 독자 여러분이 생각해 보기 바란다.

정작 이 연산자에서는 괄호가 필요하지 않다. 조건 ? A:B=100 이라고 하면 (조건 ? A:B)=100이 되며, 100 대입 연산은 3항의 B에만 연결되는 게 아니라 ? : 연산의 결과 전체에 걸린다. ? : 의 우선순위가 =보다 높기 때문에 =보다 먼저 계산되기 때문이다.

<날개셋> 한글 입력기로 복잡한 수식을 다뤄 본 분들은 이미 아시겠지만, 이 프로그램은 사용자가 입력한 수식을 어느 정도 자동으로 간소화를 한다. 상수 연산은 미리 계산을 해 버리며, 100/0나 2=A 같은 뻔한 에러는 미리 지적해 준다. 그리고 우선순위 규정상 굳이 칠 필요가 없는 괄호도 알아서 제거를 해 버린다.

(A+B)-C는 A+B-C로 바뀌며, 이와 비슷한 맥락으로 (조건 ? A:B)=100도 그냥 조건 ? A:B=100으로 바꾼다. 이건 프로그램의 오동작이 아니므로 놀라지 말고 수식을 사용하면 된다.

그런데 비주얼 C++ 같은 요즘의 C/C++ 컴파일러들은 ? :를 본인이 생각한 것처럼 취급하지 않는 것 같다.
A==100 ?B:C=400 라고 하면 =400은 3항의 C에만 붙지 B에는 붙지 않는다. (A==100 ? B:C)=400이라고 해 줘야 한다.
또한 ?와 : 사이에 있는 2항은 사이에 대입이나 콤마 같은 연산자(우선순위가 ? :보다 한참 더 낮은!)가 괄호 없이 연결되어 있어도 알아서 2항의 일부라고 인식해 주는 듯.
물론, 그렇다고 해서 A=조건 ? 2항: 3항 같은 문장이 있으면 A=까지 조건으로 끌어들이지는 않는다.

이런 세세한 동작 방식에 대해서 정보를 얻고 싶어서 비주얼 C++ 도움말을 찾아봐도, ? :는 대입 연산자보다 우선순위가 높다던가, 2항과 3항의 타입이 서로 다를 때 연산자 값이 정해지는 원칙 같은 원론적인 말밖에 없다. 그 말대로라면 무조건 내 프로그램처럼 괄호를 써야만 할 텐데 말이다.

그 간단한 ? : 연산자에도 의외로 복잡한 사연이 있다는 걸 알 수 있다.
어쨌든 내 프로그램은 ? : 안에 대입이나 콤마 연산을 포함시키려면 무조건 괄호를 써야만 하는 구조가 앞으로도 유지될 것이다.

Posted by 사무엘

2011/06/05 19:20 2011/06/05 19:20
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/521

1. 운영체제의 기반 언어

윈도우 운영체제의 기반 언어는 C이다. 유닉스만 C 기반이 아니다. ^^
물론 더 생산성이 뛰어난 MFC도 있고 닷넷 프레임워크도 있으며, 고급 기능 중엔 GDI+처럼 일부 C++ 기반으로 제공되는 API도 있다. 그러나 제일 아래를 들여다보면 역시나 C언어 냄새가 팍팍 나는 윈도우 API가 짱이다.

여기서 기반 언어라 함은, 운영체제가 자신의 기능을 어떤 언어의 바이너리 수준에 맞춰 직통으로 제공하냐와 관계가 있다.
문자열이 그 좋은 예 중 하나이다. C언어 기반인 운영체제에서는 0번 문자 문자열(null-terminated string)을 사용하는데, 파스칼이나 베이직처럼 0번 문자 문자열을 사용하지 않는 언어는 운영체제와 문자열을 주고받을 때 약간의 오버헤드를 감수해야 한다.

뭐, 0번 문자 문자열이라는 개념 자체가 C언어가 원조이지는 않은 것 같다만... 과거 도스의 API는 C 수준의 계층조차도 없어서 운영체제 API 호출은 닥치고 레지스터에 값 설정하고서 어셈블리 인터럽트를 날리는 식이었다. 함수 이름 같은 건 없고 인터럽트 번호만 존재했다.

한편, C보다 더 상위에 있는 C++은 함수 이름의 mangling(오버로딩 때문에 이게 반드시 필요함) 방식이 컴파일러마다 전혀 통일되어 있지 않아서 난리이며, 이는 C++ 클래스 라이브러리의 바이너리 배포를 어렵게 하는 요인이다. 닥치고 오로지 함수 이름만 알고 있으면 되는 C에 비해 C++은 함수 링킹이 얼마나 복잡한가? 함수 호출 한번 할 때 매개변수 개체에 대한 생성자, 소멸자, 복사 생성자 처리하는 것도 꽤 어려운 일이다.
그러나 만약 밑바닥부터 C++을 기반으로 만들어진 운영체제가 있다면, 그 방식도 응당 표준화가 되어 있을 것이다.

이런 부류의 지저분한 언어 계층의 바이너리 표준을 통합해서 소프트웨어의 컴포넌트화를 좀 수월하게 하려고 MS가 만든 녀석이 바로 COM이며, 게임계에서 유명한 DirectX가 대표적인 COM 기반 API이다.

컴퓨터 시스템이 발달하면서 이렇게 운영체제의 기반 언어도 당연하지만 점차 상위 단계의 언어로 올가라가는 경향이 있다.
닷넷 프레임워크의 기반 언어는 잘 알다시피 C#이다. 아예 자바 기반 운영체제도 있다고 들었다. 그래서 요즘 3대 메이저 스마트폰은(윈도우 모바일, 안드로이드, 아이폰) 앱 만드는 언어가 서로 다 다르다.

덧붙이자면, 어느 운영체제의 기반 언어가 되기에 충분할 정도로 C스러운 이념을 지닌 언어들과는 달리, 파이썬(Python)은 뭔가 독자적인 위상이 있는 인터프리터 지향 언어이고 루아(Lua)는 host 언어와의 glue를 지향하여 특히 게임 개발처럼 코드와 데이터의 경계가 모호한 분야에서 자기 살 길을 찾은 언어인 것 같다. 운영체제의 바이너리 기반 언어라기보다는 매크로 언어가 되기 좋은 언어라고나 할까?

2. Objective C

아이폰 덕분에 덩달아 각광받고 있는 맥 OS의 기반 언어는 Objective C이다(이하 옵C). 정확히 말하면 코코아 API의 기반 언어라고 한다. 클래식 매킨토시 시절부터 옵C만 써 왔다는 소리인지? 그리고 하필 그런 유별난 마이너 언어를 선택한 이유가 있는지 궁금하다.

똑같이 객체 지향 언어라지만 옵C는 C++과는 구조가 생각한 것보다 굉장히 달라서 본인은 적지 않게 놀랐다. C++이 C의 큰 틀을 그대로 계승하고서 C 문법에서 이건 좀 아니다 싶은 부분만 고친 후(함수를 반드시 선언한 후 쓰게 고친 것 등) OOP 개념을 추가했다면...
옵C는 C의 strict superset인지라 C스러운 부분은 그대로 C답게 놔둔 후, Smalltalk에서 영향을 받은 OOP 문법을 그대로 추가했다.

- 옵C에서 추가된 예약어들은 앞에 @가 붙는다. 이건 C/C++에서는 전혀 쓰이지 않는 문자이다.
- 맥 OS X의 전신 NextStep에서 유래된 NS* 명칭 (MFC로 치면 Afx* 뻘 되겠다.)
- #import는 C/C++의 #include와는 달리 중복 include 방지가 자동으로 적용된다.
- C++에서는 true/false가 예약어로까지 도입되었지만, 옵C에서는 YES/NO를 쓴다.
- 클래스 메소드(C++의 static 멤버 함수)와 인스턴스 메소드(C++의 일반 멤버 함수)를 각각 +와 -로 구분하여 표기
- null pointer를 의미하는 nil이 존재한다. C++은 0x에 가서야 nullptr이 추가되었지 싶다.
- this 대신 self. void *대신 id
- 일부 C++ 컴파일러가 비표준으로 제공하는 __super 키워드가 옵C에는 있음
- 자동으로 실행되는 생성자· 소멸자 함수 같은 건 없으며, new/delete 문법도 다름

저런 건 오히려 사소한 차이일 뿐이고, 진짜 적응이 안 되는 건.. object에 대한 멤버 함수 호출이 [ ]를 동원하여 C++과는 완전히 다른 문법과 의미라는 점이다. 처음엔 “왜 이런 걸 만들었을까? 아이폰 앱은 이런 괴랄한 언어로 개발되고 있었던 거야?” 같은 생각마저 들 정도였다. 옵C는 그래도 C++보다는 훨씬 더 작고 단순하고 파싱하기 쉬운 언어이며, 컴파일 타임 위주인 C++보다는 런타임에 언어 차원에서 보장해 주는 요소가 더 많다.

C++의 클래스 멤버 함수 호출은 this 포인터만 암시적으로 추가된 일반 C 함수와 거의 다를 바 없다. 그러나 옵C는 OOP의 구현에 관한 한, C와의 호환성 내지 성능보다는 원칙에 더욱 충실한 듯하다. 멤버 함수는 메시징이라는 개념으로 구현하며, 잘은 모르지만 보내어진 메시지가 어떤 종류인지 런타임 때 파악이 가능할 정도로 그 체계가 유연하다고 한다.

C++로 클래스 라이브러리 DLL을 만들면 함수 프로토타입 하나만 바뀌어도 바이너리 호환성이 다 깨지는데(특히 그게 가상 함수였다면.. ‘더 이상의 자세한 설명은 생략’ ㄲㄲ) 그에 비하면 천국인 셈. 물론 성능 오버헤드는 있다.

또한 옵C에도 자바의 generic 같은 게 있어서 어떤 자료형이든 담을 수 있는 컨테이너 정도는 구현 가능하다고 들었다. int면 int, string이면 string만 담을 수 있고, 어떤 자료형이든 담는 컨테이너를 만들려면 Variant라는 개체 자체부터 만들어야 하는 C++ 템플릿과는 물론 살짝 다른 개념이다.

옵C는 그럼 라이브러리나 컴포넌트는 어떻게 만들고 컴파일/링크, DLL 같은 건 어떤 형태로 구현되는지 모르겠다. 어쨌든 언어 스펙을 보고 본인이 내린 결론은, C++ 코드를 옵C로 포팅하기란 쉽지 않겠다는 것. 포토샵처럼 맥 세계에서 먼저 유명했던 프로그램도 처음엔 C/C++로 개발되었다고 들었는데 맥도 C/C++로 가벼운 네이티브 코드 GUI 프로그램을 만드는 방법이 없을 리가 없을 것이다.
아, 그런데 문자열보다도 더욱 중요한 함수 호출 구현한 방법이 양 언어가 워낙 너무 다르다 보니 운영체제와의 소통은 어떻게 하려나 모르겠다. (C 스타일의 callback 함수가 제일 간단하고 짱 -_-)

옵C와 XCode에 흥미가 가긴 하지만, <날개셋> 한글 입력기가 맥에 상륙하기란 내 힘으로는 역시 무리일 것 같다.
또한, 본인은 garbage collector가 없는 건 괜찮아도, 자동으로 실행되는 생성자와 소멸자, 연산자 오버로딩, 템플릿, namespace를 갖추지 않은 언어로는 불편해서 코딩을 못 할 것 같다. ㄲㄲㄲㄲㄲㄲㄲㄲ

참고로 Objective C++라는 언어도 있다고 한다. 흠좀무..

Posted by 사무엘

2011/03/25 09:23 2011/03/25 09:23
, , ,
Response
No Trackback , 7 Comments
RSS :
http://moogi.new21.org/tc/rss/response/485

우리는 C/C++ 언어에 대해 배울 때, 이 언어는 근본적으로 컴파일과 링크를 거쳐 결과물이 만들어지며, 이 과정에서 소스 코드가 obj 파일로 바뀐다는 말을 듣는다. 그런데 이런 중간 파일들의 내부 구조는 어떨지, 최종 결과물인 실행 파일의 형태와 중간 파일 사이의 관계는 어떨지 등에 대해서 궁금하게 생각해 본 적은 없는가?

물론 obj 파일에는 컴파일된 기계어 코드가 잔뜩 들어있을 것이고 lib는 그냥 이미 컴파일된 obj 파일의 컬렉션에 불과하다. 하지만 그걸 감싸는 컨테이너 포맷 자체는 필요할 것이다.
C++의 경우, 함수의 이름을 prototype대로 decorate하는 방식이 표준으로 제정된 적이 없어서 그 방식이 컴파일러마다 제각각인 것으로 악명 높다. 그렇다면 이런 obj, lib 파일 포맷도 언어마다, 혹은 컴파일러마다 제각각인 것일까?

결론부터 말하자면, 정답은 ‘No’이다. obj, lib 같은 파일 포맷은 실행 파일의 포맷과 더불어 굉장히 시스템스러운 포맷이고, 일반적인 응용 프로그램의 개발자가 거의 관심을 가질 필요가 없는 분야임이 틀림없다. 컴파일러를 만든다거나, 골수 해커 같은 부류가 아니라면 말이다.

이런 건 그렇게까지 다양한 파일 포맷이 존재하지 않으며, 다양하게 만들 필요도 없다.
인텔 x86 기계에서는 전통적으로 인텔 사가 고안한 OMF(object module format이라는 아주 평이한 단어의 이니셜) 방식의 obj/lib 포맷이 독자적으로 쓰였다. 굉장히 역사가 긴 포맷이며, 볼랜드, 왓콤, MS 등의 컴파일러에서 다 호환됐기 때문에 서로 다른 컴파일러나 언어로 만든 obj 파일끼리도 이론적으로는 상호 링크가 가능했다. 물론, 언어별로, 특히 C++의 경우 아까 언급했듯이 decoration 방식이 다르면 명칭이 일치하지 않아 혼용이 곤란하겠지만, 이건 파일 포맷 자체의 문제는 아니었다.

그런데, 32비트 시대가 도래하면서 사정이 약간 달라졌다.
machine word의 크기가 커지고 CPU의 레지스터 구조도 달라지고.. 그에 따라 obj/lib 파일의 포맷도 일부 필드의 크기가 확장되는 등 변화를 겪게 되었으며, 인텔 사에서는 OMF 포맷을 32비트로 확장한 업그레이드 버전을 내놓았다. 마치 지금 윈도우의 PE 실행 파일도 64비트에서는 기본적인 뼈대는 그대로 유지하되, 규격이 확장된 것과 같은 이치이다.

컴파일러들은 대체로 그 규격을 따르기 시작했으나, 이때 MS에서는 꽤 과감한 결정을 내렸다.
기왕 32비트로 갈아타는 김에, 자기네가 만드는(OS/2의 밑천으로? ㄲㄲ) 순수 32비트 운영체제인 윈도우 NT에서는 공식 사용하는 실행 파일과 obj/lib 파일의 포맷을 싹 바꾼 것이다.
어디 그뿐일까? 메모리가 귀하던 1990년대에 그때 이미 유니코드를 고려하여 딱 16비트 wide string을 내부 자료 구조로 채택했다. 본인이 보기에 윈도우 NT는 출발이 굉장히 대인배스러웠다.

새로운 포맷은 단순히 구조체 필드만 32비트에 맞게 키운 게 아니라, 더 보편적인 이식성과 확장성을 고려해서 설계되었다. 코드, 데이터 등 용도별로 다양한 chunk를 둘 수 있고, CPU 정보도 넣어서 굳이 x86뿐만이 아니라 어느 플랫폼 코드의 컨테이너로도 활용할 수 있게 했다. 또한 어차피 똑같은 기계어 코드가 들어있는 파일인데 obj/lib/exe 사이의 구조적 이질감을 낮춰서 일단 컴파일된 코드의 링크 작업을 더욱 수월하게 할 수 있게 했다.

그래서 MS는 32비트 컴파일러에서는 AT&T가 개발한 COFF(Common Object File Format) 방식을 약간 변형한 obj/lib를 사용하기 시작했고, 32비트 실행 파일은 잘 알다시피 COFF의 연장선에 가까운 PE(Portable Executable) 방식을 채택했다. 이 컨벤션이 오늘날의 64비트에까지 고스란히 전해 내려오는 중이다.

그렇게 MS는 과거 유물을 미련 없이 내버렸지만, 볼랜드 컴파일러는 32비트 윈도우용도 여전히 OMF 방식을 사용했고, 왓콤처럼 당시 16비트/32비트 도스/윈도우를 모두 지원하던 컴파일러는 OMF와 COFF 방식을 혼용까지 해서 당시 개발자들에게 상당한 혼란을 끼쳤다고 한다. 윈도우 운영체제가 16비트에서 32비트로 넘어가면서 이런 것까지도 정말 넘사벽에 가깝게 세상이 바뀐 것이다. 참고로 DJGPP는 도스용 컴파일러이지만 32비트 기반이고 COFF 방식 파일을 사용한다.

1985년에 나온 윈도우 1.0 이래로 16비트 윈도우가 사용하던 NE 포맷은 chunk 같은 게 없었다. 정보 자체를 식별하는 방법이 없이 요 정보 다음엔 무슨 정보, 다음에는 무슨 정보.. 딱 용도가 고정되어 있었고, 뭔가 확장을 할 수가 없었다. 상당히 원시적인 포맷이었다는 뜻. 개인적으로 그 시절에는 컴파일과 링크가 어떻게 이뤄졌고 DLL import/export가 어떤 방식으로 되었는지 무척 궁금하다.

또 생각나는 게 있는데, 과거에 똑같은 베이직 컴파일러이지만 MS가 개발한 퀵베이직은 굉장히 C언어에 가깝고, 파워베이직은 파스칼에 가까운 빌드 모델을 사용했다. 전자의 경우 헤더 파일을 인클루드하고 소스 파일을 obj로 컴파일하고, 각종 라이브러리와 링크하고... C와 똑같지 않은지? obj/lib 파일 포맷은 당연히 인텔 OMF 방식이었다.

그 반면, 파워베이직은 파스칼처럼 unit이라는 패키지를 만들고, 그걸 간단하게 use하는 것만으로 여타 모듈의 루틴을 사용할 수 있었다. 자바, C#, D 같은 요즘 언어들이야 비효율적인 인클루드(text parsing이 필요!) 방식이 아닌 패키지 import를 선호하는 추세이지만, 그 당시 파워베이직을 개발한 Bob Zale은 분명 파스칼 언어에서 이 아이디어를 따 왔을 것 같다. 물론 그렇다고 해서 파워베이직도 기존 obj 파일과 링크하는 방식이 없는 건 아니었다.
Bob Zale과, 터보 파스칼을 개발한 필리페 칸과는 어떤 사이일지 궁금하다.

C/C++에 전처리기가 있다면, 베이직이나 파스칼 같은 언어는 주석 안에다가 메타커맨드를 넣는 방식을 써 온 것도 흥미로운 점.
아울러, tpu, pbu 같은 저런 unit 파일은 분명 컴파일된 기계어 코드가 들어있는 라이브러리에 가깝지만, 당연히 컴파일러 vendor마다 파일 포맷이 제각각이다. 마치 퀵베이직의 QLB(퀵라이브러리) 파일이 아주 독자적이고 특이한 실행 파일인 것처럼 말이다.

Posted by 사무엘

2010/11/16 10:29 2010/11/16 10:29
, , , , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/412

C 언어는 다른 언어가 언어 차원에서 기본으로 제공해 주는 상식적인 기능이 없고, 대신 별도의 함수 호출에 의존하는 형태인 게 몇 가지 있다. 거듭제곱 연산이 대표적인 예이고, 문자열 타입도 언어가 자체 제공하지 않는다. 사실은 동적(힙) 메모리를 할당하는 기능 자체가 아예 없다.

그 이유는 간단하다. 저런 기능들은 컴퓨터 CPU 명령 차원에서 직관적으로 구현 가능하지 않기 때문이다. 그래서 연산자가 그렇게도 많다는 C 언어는 거듭제곱 연산자가 없으며 pow라는 함수를 호출해야 한다. (그나마 파스칼은 그런 함수조차도 없기 때문에, exp와 log 함수 조합으로 임의의 수의 거듭제곱을 얻어내야 한다.)

메모리 할당도 마찬가지이다. 메모리 관리는 CPU뿐만이 아니라 해당 운영체제/플랫폼이 담당하는 비중도 크기 때문에, 작은 언어인 C가 언어 차원에서 자체 제공하지는 않는 것이다. malloc, free, realloc 같은 함수를 써야 한다. 그러면 윈도우 운영체제의 C 라이브러리는 내부적으로 또 HeapCreate, HeapAlloc 같은 더 저수준의 윈도우 API를 이용해서 그런 메모리 관리 기능을 구현해 준다.

그런데 C++에서는 드디어 동적 메모리 할당과 해제 기능이 언어 차원에서 연산자로 추가되었다. 바로 new와 delete 연산자이다. 그때까지 영단어로 이루어진 연산자는 sizeof가 고작이던 것이 새로 추가되었으며, 그 후로 *_cast라든가 typeid 등 여러 영단어 연산자가 C++에 추가되었다. 메모리 할당이라면 몰라도 개체의 생성과 소멸에 따른 생성자와 소멸자 함수 호출은 언어 차원에서 책임져 줘야 하는 영역이기 때문에 별도의 연산자가 생긴 것이다.

연산자가 추가된 덕분에 일단 type casting이나 sizeof 계산을 할 필요가 없게 된 것은 좋다.

pData = new DATA[nCount];
pData = (DATA *)malloc(sizeof(DATA)*nCount);

물론 번거로운 문법 정도야 C 시절에도 매크로로 대체 가능했겠지만 말이다.

#define NEW_C(T, N)  (T *)malloc(sizeof(T)*(N))

그러나 new 연산자는 malloc 함수처럼 범용적인 void* 포인터를 되돌리는 건 지원하지 않으며, 해당 타입의 배수가 아닌 크기의 메모리도 할당할 수 없다. 그렇기 때문에 가변 길이 구조체 같은 메모리를 할당하는 건 오히려 더 불편할 수 있다.
또한 할당 아니면 해제만 지원되지 C 함수처럼 realloc 기능도 없다. C++의 메모리 연산자는 오로지 개체의 생성과 소멸에만 초점을 둔 것이다. 그렇기 때문에 이것이 기존 C의 메모리 관리 함수를 완전히 대체하지는 못할 것으로 보인다.

new 연산자로 데이터 타입을 지정한 뒤에는 new DATA[100] 처럼 배열 첨자가 올 수 있고, 아니면 new Object(x, y)처럼 해당 개체의 생성자 함수에다 넘겨 줄 인자가 올 수도 있다. 두 문법 중 오로지 하나만 허용된다.
그러므로 생성될 때 생성자 함수 인자 전달이 필요한 개체는 배열로 만들 수 없다. 그러나 인자가 필요한 생성자 함수가 존재한다 할지라도, 전부 default argument가 있어서 대체가 가능하다면 배열을 만들 수 있다.

1. new operator vs operator new

이 new 연산자(new operator)는 내부적으로 operator new라는 함수를 호출하는 형태로 구현되어 있으며, 이 특수한 함수는 나름 오버로딩이 가능하다! (delete도 마찬가지) 비록 개체를 생성하여 생성자 함수를 호출한다는 기본 기능은 C++의 특성상 불변이지만, 이 연산자가 하는 일 중 메모리를 할당하고 해제하는 계층은 customize가 된다는 뜻이다.

void *operator new(size_t size);
void operator delete(void *ptr);

operator new 함수는 첫째 인자는 무조건 포인터 크기와 같은 부호 없는 정수형이어야 한다. 부호 있는 정수형도 허용되지 않는다. 그리고 리턴값은 void *이어야 한다.
한편 delete 함수는 첫째 인자는 무조건 void *이어야 하고, 함수의 리턴값은 void여야 한다. 일단 기본적인 생김새는 malloc, free와 완전히 일치한다는 뜻.

당연한 말이지만 이 함수만 단독 호출이 가능하다.
malloc(100)을 쓸 곳에 그냥 operator new(100) 이라고만 써도 된다. 그러면 어차피 new char[100]과 비슷한 효과가 나게 된다. C++ 언어는 이 함수들의 기본 구현을 라이브러리 차원에서 제공하고 있다. 만약 기본 C/C++ 라이브러리를 사용하지 않으면서 new/delete 연산자도 쓰고 싶다면 내가 직접 이들 함수를 구현해 줘야 한다.

거기에다 나만의 인자를 추가한 operator new/delete를 만들 수 있다. 예를 들어, C/C++ 라이브러리가 사용하는 프로세스 기본 힙이 아닌 다른 곳에다가 메모리를 할당하고 싶다면 이렇게 코드를 써 주면 된다.

void *operator new(size_t size, HANDLE hHeap)
{ return HeapAlloc(hHeap, 0, size); }

HANDLE hMyHeap = HeapCreate( ... );
Object *pt = new(hMyHeap) Object( ... );

new 바로 옆에다가 전달해 주는 인자는 operator new의 둘째 이후의 인자로 전달된다. delete도 비슷한 방식으로 오버로딩 가능하다. 놀랍지 않은지?

모든 개체과 기본 자료형에서 통용되는 global scope의 operator new/delete가 있는 반면, 특정 클래스에서만 통용되는 new/delete 함수를 만들 수도 있다. 함수 프로토타입은 동일하다. 이 new/delete 함수는 굳이 static을 지정해 주지 않더라도 언제나 static으로만 선언되기 때문에, 클래스 내부에 있더라도 가상 함수 지정이나 this 포인터는 지원되지 않는다. 또한 생성자· 소멸자· 대입 연산자 등과는 달리, 파생 클래스로 상속도 된다.

2. new operator vs new[] operator

그런데, 더욱 충공깽한 사실은 new와 new[] (delete도 delete[])가 구분되어 있다는 것. 이런 구분이 언제 필요하냐 하면 소멸자 함수가 존재하는 개체의 배열을 선언할 때이다. (물론 기본 자료형이 아니라 개체를 배열로 만드는 경우는 드물지만 말이다.)
우리가 요청하는 메모리의 크기와 실제로 운영체제로부터 할당되는 메모리의 크기는 여러 가지 요인으로 인해 일치하지 않는 경우가 있으며 후자가 전자보다 대체로 더 크게 잡힌다.

배열을 delete로 해제할 때는 여기에 있던 배열 각 원소들에 대해서도 소멸자 함수를 일일이 호출해 줘야 하는데, 원래 여기에 개체가 정확하게 몇 개 있었는지를 메모리 블록만 봐서는 알 수 없게 되는 것이다.
그래서 1980년대에 C++이 처음 등장했을 때는 delete 연산자에다가 배열의 개수까지 지정을 해 줘야 했다.

int *arr = new int[nCount];
Object *ptr = new Object[nCount];
(....)
delete arr; //기본 자료형은 그냥 이렇게 지워도 무방
delete[nCount] ptr; //이놈은 흠좀무

C++은 그렇잖아도 garbage collector도 없어서 불편해 죽겠는데 배열의 원소 개수까지 프로그래머가 관리해야 한다니, 이게 말이나 되는 소리인가?

프로그래머의 원성이 빗발친 덕분에 시스템이 바뀌었다. 배열의 원소 개수는 C++이 메모리를 할당하면서 내부적으로 알아서 관리하도록 바뀌고 원소 개수를 생략 가능해졌다. 그러나 그래도 이게 배열이라는 힌트는 알아서 줘야 한다. 배열일 때와 그렇지 않을 때 C++이 메모리를 관리하고 인식하는 방식은 여전히 서로 약간 다르기 때문이다.

delete arr; delete[] ptr; 를 해도 괜찮다는 소리이지 delete arr; delete ptr; 처럼 구분이 완전히 사라진 건 아니다.

그래서 operator new/delete를 오버로드했다면 operator new[]/delete[]의 오버로드도 지원된다. 둘은 인자의 의미나 하는 일의 차이는 전혀 없다. 단지 new[]의 경우, 연산자의 리턴값 포인터에다가 곧바로 개체가 저장되지는 않는다는 차이가 존재할 뿐이다. 배열 원소 개수가 앞부분에 먼저 저장되고 그 뒤의 공간부터가 쓰인다.

자바와 C#에서 볼 수 있듯, 요즘 대세는 개체는 무조건 new로 선언하는 것이다. 그게 언어 문법까지 더 명료하게 만들어 주는 효과까지 있다. 그러나 C++은 기본 자료형이든 개체든 스택과 힙에 모두 선언 가능하고, 심지어 함수 전달도 둘 다 call by name이나 reference 방식이 모두 가능하다.

일반적으로 컴파일러들은 C++의 operator new/delete도 내부적으로는 C의 malloc/free로 구현한다. 기능이 완전히 동일한데 둘의 동작 방식이 달라야 할 이유가 전혀 없기 때문이다. 그러나 원칙대로라면 malloc으로 할당한 포인터를 delete로 해제한다거나, new로 할당한 메모리를 free로 해제하는 것은 허용되지 않는 비추 행동이다. 그렇게 섞어 쓰지는 않는 게 좋겠다.

Posted by 사무엘

2010/07/28 08:27 2010/07/28 08:27
, ,
Response
No Trackback , 6 Comments
RSS :
http://moogi.new21.org/tc/rss/response/332

C++에는 namespace라는 엄청난 키워드가 존재한다.
namespace는 소스 코드에 존재하는 수많은 명칭(심볼)들로 하여금 이들이 통용되는 구획을 강제로 구분해 준다. (명칭의 decoration도 달라지기 때문에, 링크 때도 동명의 심볼들이 서로 구분 가능함)
방대한 프로그램을 짜고 특히 남이 만든 여러 라이브러리들을 한데 뭉뚱그려 관리하다 보면 함수나 전역 변수 이름, 심지어 매크로 같은 게 겹쳐서 링크 시 충돌이 있을 수 있다. 이때 namespace는 그런 문제에 대한 근본적인 해결책이 되어 준다.

C++은 C에 비해 scope이라는 개념이 더욱 발달했다.
여기서 말하는 scope이란, 단순히 전역 변수냐 지역 변수냐 하는 생명 주기 차원이 아니라, 어떤 심볼이 언어의 문맥 차원에서 인식되고 접근이 허용되는 범위를 일컫는다.
가령, C++ 클래스 내부에 있는 static 변수는 생명 주기로 말하자면야 C의 전역 변수와 다를 바가 없다. 그러나 단순 전역 변수와는 확연하게 다른 scope을 지니고 있다. 그래서 :: 같은 연산자도 생겼다.

예전에는, 특히 C 시절에는 global이라는 기본 namespace 하나밖에 없는 것과 마찬가지였지만 C++에서는 나만의 namespace를 정의할 수 있고, 심지어 이중 삼중으로 namespace 안에 또 namespace를 만들 수도 있다. 심볼들의 입체적인 관리와 구별이 가능해진 것이다.
사실 namespace는 90년대에 나중에 추가된 키워드로, 도스 시절의 볼랜드 C++ 같은 컴파일러에서는 지원도 되지 않는다. (MFC 역시 namespace는커녕 템플릿조차 없던 시절부터 만들어져 온 클래스 라이브러리인지라, namespace를 사용한 흔적이 없음)

그런데, namespace가 하는 일은 클래스가 하는 일과 좀 중복이 있어 보인다.
클래스도 그 자체가 이미 자신만의 새로운 scope을 만들어 낸 것이기 때문이다.
클래스 내부에 public으로 선언된 static 변수 내지 함수하고,
namespace 내부에 존재하는 전역 변수 내지 함수는 언뜻 보기에 위상이 완전히 똑같다.

밖에서는 클래스::이름, 또는 namespace::이름 이렇게 ::을 써서 호칭하는 것마저 동일하다.
클래스도 안에 클래스 내지 구조체가 중첩해서 존재할 수 있으며, 심지어 클래스 내부에서만 통용되는 enum이나 typedef를 선언하는 것도 가능하다.
그럼 도대체 namespace만의 특징은 무엇이 있을까? 아래의 코드를 생각해 보자.

namespace NS {
   class A {};
   void f( A *&, int ) {}
}

//void f(NS::A *&, int) {} //이게 뭘까?

class CS {
public:
   class A {};
   static void g( A *&, int ) {}
};

이렇게만 보면 NS라는 namespace에 소속된 클래스 A와 전역 함수 f,
그리고 CS라는 클래스에 소속된 클래스 A와 전역 함수 g는 서로 그게 그거 같고 정말 차이를 발견할 수 없어 보인다.
다만, class나 struct와는 달리 namespace는 뭔가 인스턴스화하는 자료형을 만드는 것이 아니기 때문에, 닫는 중괄호 뒤에 세미콜론을 붙일 필요가 없다. 뭐, 그 정도 차이는 존재한다.

이들 각 심볼을 외부에서 접근하는 방법도 완전히 동일하다. 아래 코드를 보라.

NS::A *pfm = NULL;
NS::f(pfm, 0); //하지만 바로 f(pfm, 0)만 해도 된다. 이유는 나중에 설명

CS::A *qfm = NULL;
CS::g(qfm, 0);

그런데, namespace는 클래스에 없는 부가 기능이 좀 있다.

첫째, 바로 ADL(Argument dependent name lookup)이라는 기법이다.
C++ 컴파일러는 함수의 argument의 타입으로부터 함수의 소속 scope를 자동 추론하는 기능이 있다.
namespace NS에 속해 있는 f를 호출할 때 굳이 NS::를 할 필요가 없다.
왜냐하면 f가 받는 함수 인자 중에 이미 NS에 소속된 자료형이 존재하기 때문에, 컴파일러는 이 f를 먼저 global scope에서 살펴봐서 없으면 NS namespace 안에서도 찾아보게 된다.

함수의 인자를 이용하여 함수를 추정한다는 점에서는 함수 오버로딩의 확장판이라고 볼 수도 있겠다.
사실, 위의 소스에서 주석을 쳐 놓은 global scope의 f 함수까지 정의한다면 컴파일러는 어느 f 함수를 선택해야 할지 모호하다면서 에러를 낸다.
이런 기능은 클래스에는 존재하지 않는다. g 함수를 호출할 때는 매번 CS::g를 해 줘야 한다.

둘째, using 키워드이다.
반복되는 타이핑을 좀 줄이고 싶어하는 건 프로그래머들의 공통된 희망 사항이다.
타입 선언을 좀더 간편하게 하기 위해서 C/C++에는 typedef라는 키워드가 있고, 베이직이나 파스칼에는 구조체 참조를 좀더 간편하게 하려고 With 같은 키워드가 있다.

그와 마찬가지로 C++에는 여타 namespace에 있는 명칭을 매번 :: 연산자 없이도 바로 참조 가능하도록 using namespace 선언을 제공한다. using namespace std; 처럼 말이다.
using namespace NS를 한번 해 주면, 그 뒤부터는 NS::A *pfm 마저도 A *pfm로 축약 가능해진다.
using의 용법으로는 또 다른 것도 있는데, 설명서를 읽어 봐도 잘 모르겠다. 정말 무진장 복잡하고 저런 걸 언제 어디서 써먹으면 될지 영 감이 안 잡힌다. =_=;;
다만, namespace가 아니라 클래스에 의해 만들어진 scope에 대해서는 그런 것 역시 지원되지 않는다.

셋째, namespace p = FS; 처럼, namespace에다 별명(alias)을 붙여 쓰는 것도 가능하다. 길고 복잡한 다단계 namespace를 손쉽게 축약하는 방법이다. 저런 문법도 있다니, 가히 충격과 공포.

끝으로, 이름 없는 namespace는 마치 C 시절의 static 전역변수/함수처럼, 해당 번역 단위(소스 코드; translation unit) 바깥으로 함수나 변수 심볼이 노출되지 않게 하는 역할을 한다는 것도 알아 두면 좋다.
이 정도 되면 namespace는 C++ 언어에서는 단순히 클래스 이상으로 자신만의 역할이 있다고도 볼 수 있겠다.

가장 먼저 언급한 ADL에 대해서는 비판은 있다. namespace에다가 일종의 예외 규정을 만드는 것이나 마찬가지이기 때문에 C++ 문법을 더욱 복잡하게 하고 컴파일러 만들기도 난해한 언어로-_- 만드는 데 일조했기 때문이다. 그러나 프로그래밍의 편의를 위해서 ADL은 어쩔 수 없이 꼭 필요하기도 하다. 이게 없으면 다른 namespace에 소속되어 있는 클래스의 오트젝트에 대해서는 연산자 오버로딩조차도 제대로 못 하게 되는 경우가 생길 수도 있기 때문이다.

참고로 자바나 C#처럼 C++보다 나중에 등장한 본격 객체 지향 언어들은 C++처럼 global scope이라는 게 존재하지 않는다. 전역 함수나 전역 변수라는 게 애초부터 존재하지 않으며 모든 심볼들은 무조건 클래스에다 소속되어 있어야 한다. 또한 이런 언어들은 C++ 같은 텍스트 include라든가 링크라는 개념이 없으며, 클래스가 곧 패키지요 namespace의 형태로 구조가 잘 짜여 있다. 그래서 C++처럼 namespace를 별도로 갖고 있지는 않다.

Posted by 사무엘

2010/07/07 08:44 2010/07/07 08:44
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/313

C++의 typename 키워드

C++ typename 키워드의 용법은 크게 두 가지이다. 그러나 주된 목적은 동일하다. 다음에 나오는 명칭이 변수도 될 수 있고 변수의 타입 이름이 될 수도 있는 문맥일 때, 이것이 명백하게 후자임을 알려 주는 것이다.

먼저, typename은 잘 알다시피 템플릿 인자를 선언할 때 class 대신 쓸 수 있다.

template<class T> void Swap(T& a, T&b );
template<typename T> void Swap(T& a, T&b );

위의 두 줄은 의미상 완전히 동일하다.
템플릿이 C++ 언어에 처음으로 추가되었던 당시에는 typename이라는 키워드가 없었고 템플릿 인자를 선언할 때에도 class를 썼던 것이다.
그러나 이것은 의미상으로 문제가 있었다. 아래의 예를 보자.

template <class T, int N>
class MyClass {
public:
    T data[N];
};

MyClass<int, 20> obj;

템플릿 인자로는 잘 알다시피 자료형 이름 내지 정수 숫자가 올 수 있다.
그런데 int N에 해당하는 템플릿의 인자로는 마치 일반 함수의 인자처럼 int 값에 해당하는 20이 쓰였다. 그런데, class T에 해당하는 첫째 인자는 그럼 T라는 클래스에 속하는 개체가 쓰인단 말인가?

전혀 그렇지 않다. 여기서는 진짜로 특정 type에 속하는 20 같은 값이 아니라, type 자체가 인자로 와야 하기 때문이다.
그래서 의미상 완전히 다르다는 걸 표현하기 위해 typename이라는 키워드를 class 대신 사용할 수 있게 되었다. 매우 바람직한 조치이다. class라는 키워드는 이제 진짜로 새로운 클래스를 선언할 때만 쓰도록 하자.

그리고 다음 용법이 개념상으로 진짜 중요하다. scope resolution과 관계가 있다.
A가 클래스 이름일 때 A::B라는 표현을 썼다면, C++의 특성상 B는 A의 멤버 변수일 수도 있고, A 클래스 내부에 선언된 다른 타입(클래스, 구조체 따위)의 이름일 수도 있다. 그 클래스 내부에 무엇이 선언돼 있냐에 따라서 해석이 달라진다. 처리가 어렵다.

그런데 설상가상으로 A 자체가 실제로 무슨 타입이 들어올지 모르는 템플릿 클래스의 인자라면? B에 대한 해석은 그야말로 귀에 걸면 귀걸이, 코에 걸면 코걸이가 될 수밖에 없어진다. A의 실체가 무엇이건 B의 정체는 컴파일 시점 때 다 결정되어야 하는데 말이다.

그럴 때 typename A::B를 써 주면, B는 A가 무엇이건 상관없이 변수가 아니라 말 그대로 type 이름으로 처리되어야 함을 컴파일러에게 알려 준다. 이 키워드는 절대적인 모호성 해결보다는, C++의 문법 해석의 복잡성을 좀 줄이고 컴파일러 개발을 더 수월하게 만들려는 목적이 더 크다고 보면 정확하다.
자, 이번에도 이해를 돕기 위해 예제 코드를 보자.

template<typename T>
class MyClass {
public:
    struct MYSTRUCT {
    };
    static MYSTRUCT dat;

    typename T::COMP pp;
};

struct SAMPLE {
    struct COMP {
    };
};

MyClass<SAMPLE> obj;

바로 이런 식으로 MyClass가, 자신의 템플릿 인자 T가 내부적으로 또 갖고 있는 COMP라는 구조체를 이용하기 위해서는 pp를 저렇게 typename을 줘서 선언해야 한다. COMP를 자료형 이름임을 명확하게 해 줄 필요가 있다.

비슷한 이유로 인해,

template<typename T>
typename MyClass<T>::MYSTRUCT MyClass<T>::dat;

템플릿 클래스 내부에 있든 구조체 형태로 된 static 멤버를 밖에서 또 정의해 줄 때, typename을 넣어 줘야 한다.
똑같이 MyClass<T>::로 시작하는 명칭이지만 typename이 선행된 MYSTRUCT는 자료형이고, dat는 멤버 변수로 인식되는 근거가 여기에 있는 것이다.
늘 생각하는 것이지만 C++의 세계는 참으로 심오하다. -_-;;

Posted by 사무엘

2010/06/28 08:56 2010/06/28 08:56
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/305

C++의 템플릿은 두말할 나위도 없이 매우 강력하고 유용한 개념이다.
C++에다가 제네릭/메타 프로그래밍--프로그램을 만드는 프로그램.. 즉 더욱 추상화된 기법--의 가능성을 무한히 열어 줬기 때문이다.

swap, min, max 같은 것부터 시작해서
옛날에는 문법적으로 매우 불완전하기 짝이 없는 매크로를 쓰고 위험한 typecasting을 해야 했던 것을, 템플릿 덕분에 언어의 정식 문법으로 아주 깔끔하고 type-safe하게 구현할 수 있게 된 게 많다.

그리고 static 배열의 크기(원소 개수)를 되돌리는 매크로인 ARRAYSIZE도 생각해 보자.
C 시절에는 sizeof(x)/sizeof(x[0]) 와 같은 식으로 구현했다. 물론 이 값들은 모두 컴파일 시간 때 이미 결정이 되기 때문에 실제로 나눗셈 연산이 일어나지는 않는다.

하지만 템플릿을 이용하면...
template<typename T, size_t N> char (*__GetArraySize(T (&n)[N]))[N];
#define ARRAYSIZE(x)   sizeof(*__GetArraySize(x))

자 이게 무슨 의미인지 이해가 가시는가?
무슨 타입인지는 모르겠지만 어쨌든 N개짜리 배열의 참조자를 받아서 N개짜리 char형 배열의 포인터를 되돌리는 함수를 템플릿으로 선언한다.
그 후, N의 함수의 리턴값을 역참조한 배열의 크기를 sizeof로 구하면... 당연히 char형 배열의 크기는 본디 배열의 원소 개수와 일치할 수밖에 없게 된다. 함수의 몸체를 정의할 필요조차 없다! 마치 &( ((DATA*)NULL)->member ) 가 에러를 일으키지 않고 멤버 오프셋을 되돌려 주는 것과 같은 이치이다.

템플릿이 아예 배열의 참조자를 받게 함으로써 좋은 점은, 이 매크로에다가 static 배열이 아니라 단순 포인터를 집어넣으면 컴파일 에러가 발생하게 된다는 것이다. 단순 sizeof(A)/sizeof(A[0])보다 타입 safety도 보장되고 훨씬 더 좋다.
(), *, [] 등이 뒤섞인 복잡 난해한 C/C++ type string 읽는 법에 대해서는 이전에 별도로 글로 다룬 바 있다.

template<typename T, size_t N> size_t ARRAYSIZE(T (&n)[N]) { return N; }

물론 위와 같이 코드를 쓰면 더 간단하고 알아보기도 쉽게 동일한 효과를 이룰 수 있지만, 최적화를 하지 않은 디버그 빌드는 불필요한 함수 호출이 계속 일어나는 문제가 있다. 배열의 크기 정도는 컴파일 타임 때 모든 계산이 딱 일어나게 하는 방법을 쓰는 게 더 좋을 것이다.

이렇게 템플릿은 매우 편리한 개념이긴 하나, 한계도 분명 있다.
템플릿은 동일한 패턴을 지닌 여러 다른 코드들을 찍어내는 ‘틀’과 같다. 하지만 이 틀 자체는 한 번만 정의해 놓고 링크 타임 때 여러 오브젝트 파일 사이를 자유자재로 넘나들게 할 수는 없다. 최적화처럼 기술적으로 여러 난관이 있기 때문이다. 템플릿 인자로 들어온 타입이 int일 때, double일 때, 심지어 개당 100바이트가 넘는 구조체일 때 이들에 대한 각종 비교나 대입 연산과 최적화 방식과 코드 생성 방식은 완전히 천차만별이 될 수밖에 없다.

이런 이유로 인해 템플릿의 정의 효과는 오로지 한 소스 코드, 한 translation unit 안에서만 유효하며, 템플릿 클래스나 함수는 모든 소스 코드에 헤더 파일의 형태로 매번 include되어야 한다. 몸체까지(body; definition; implementation) 죄다 말이다. 일반적인 클래스의 함수처럼 선언 따로, 정의 따로일 수가 없다. 사실은 템플릿 코드에 대한 에러 체킹 자체도 템플릿이 인자가 들어와서 어떤 형태로든 실현(realize)이 됐을 때에야 할 수 있다.

그러니 템플릿으로 구축된 각종 함수와 클래스는 소스 코드가 노출될 수밖에 없으며, 그 소스 코드를 고치면 템플릿을 include하는 모든 소스 파일들이 재컴파일되어야 하는 등 프로그래밍 상의 한계가 결코 만만한 수준이 아니다. 하지만 이 한계는 C++ 언어의 컴파일/링크 모델이라든가 기존 컴파일러들의 오브젝트 파일 포맷 내지 컴파일러/링커의 동작 방식이 근본적으로 바뀌지 않는 한 극복되기 쉽지 않을 것이다.

이런 구조적인 불편을 해소하고자 C++ 표준 위원회가 제안한 것은 export 키워드이다.
흔히 import/export하면 윈도우 프로그래밍 세상에서는 DLL 심볼을 내놓거나 가져오는 개념을 떠올리는데, 이 문맥에서는 그런 건 아니다. 한 translation unit에 존재하는 템플릿 함수 구현체를 다른 translation unit이 그 경계를 초월하여 링크 타임 때 가져다 쓸 수 있게 하는 흠좀무한 개념이다. 즉, 템플릿 몸체에 대한 export를 뜻한다.

헤더 파일에다가는

export template<typename T> void Swap(T& a, T& b);

이라고 해 놓고 모처의 cpp 파일 한 군데에다가는

export template<typename T> void Swap(T& a, T& b)
{
    T c(a); a=b, b=c;
}

이런 식으로 써 놓음으로써,
Swap 함수는 export라고 마크가 되어 있으니 자기 translation unit에서만 쓰지 말고, 다른 단위에서도 필요하면 링크 때 가져다 쓸 수 있게 한다는 게 당초 의도였던 모양이다. (인터넷 검색을 해 보니)
템플릿으로 들어간 클래스 멤버 함수에 대해서도 마찬가지이다. export가 없으면 저 함수 body는 마치 static 변수/함수처럼 그 소스 파일 내부에서만 유효하고 다른 소스 파일에서는 링크 에러가 나게 될 것이다.

그러나 이것을 본 컴파일러 개발사들은 '이뭐병, 이딴 걸 무슨 얼어죽을 표준안이라고 내놓냐' 하는 반응이었고..
비주얼 C++, gcc 등 유수의 컴파일러들은 이 키워드의 구현을 포기/거부하고 말았다.
비주얼 C++의 경우 도움말에 Nonstandard Behavior로 자기는 이 키워드를 지원하지 않는다고 당당히 명시까지 되어 있다. 2010은 모르겠고 2008까지도 마찬가지임.
하지만 마이너급 컴파일러 중엔 export를 구현 안 한 놈이 없는 건 아니라고 한다. 흠좀무.

컴파일해 놓은 코드를 짜깁기만 하는 게 링크인데, 저걸 구현하려면 링크에다가 다시 컴파일을 하고 재링크(?) 과정을 집어넣어야 한다. C/C++의 정상적인 빌드 루트와는 정면으로 모순되는 과정을 요구하는 것이다. 그래서 컴파일러 개발사들이 떡실신한 것이다.

어쨌든 이런 이유로 인해서 export는 C++의 흑역사 키워드로 전락해 있다.
옛날에 MS는 링크 과정을 최대한 간단하게 만들려고 COFF 방식 obj 파일과 PE 방식 exe 파일을 채택했다고 하던데, 하지만 요즘은 워낙 translation unit을 넘나드는 링크 타임의 코드 생성과 전역 최적화 같은 기술이 대세가 돼 있다 보니, export 키워드의 의도가 옛날만치 그저 병맛나게 들리지만은 않을 것 같은 생각이 들기도 한다.

마치 유명무실하던 auto가 리모델링되고 서울 지하철 5호선 마곡 역이 13년만에 부활했듯이 export 키워드도 의도 자체는 좋은데.. 언젠가 부활할 날이 올 수 있지 않을까? 하지만 C++의 차세대 표준인 C++0x에서는 export를 아예 빼 버리고 백지화하자는 제안까지 나온 상태이니, 과연 지못미이다.

Posted by 사무엘

2010/06/12 09:27 2010/06/12 09:27
, ,
Response
A trackback , 11 Comments
RSS :
http://moogi.new21.org/tc/rss/response/293

« Previous : 1 : ... 3 : 4 : 5 : 6 : 7 : 8 : Next »

블로그 이미지

철도를 명절 때에나 떠오르는 4대 교통수단 중 하나로만 아는 것은, 예수님을 사대성인· 성인군자 중 하나로만 아는 것과 같다.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/12   »
      1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31  

Site Stats

Total hits:
1698903
Today:
24
Yesterday:
537