« Previous : 1 : 2 : 3 : 4 : 5 : 6 : 7 : Next »

오늘은 C++에서 기초적이면서도 아주 심오하고 기괴한 문법 중 하나인 연산자 오버로딩에 대해서 좀 살펴보도록 하자.
정수, 포인터 같은 기본 자료형뿐만 아니라 사용자가 만든 임의의 개체도 연산자를 통해 조작할 수 있으면 천편일률적인 함수 호출 형태보다 코드가 더 깔끔하고 보기 좋아지는 효과가 있다. 물론, 아무 논리적 개연성 없이 오버로딩을 남발하면 코드를 알아보기 힘들어지겠지만 말이다.

C++은 연산자 오버로딩을 위해 operator라는 키워드를 제공한다. 그리고 개체에 대한 연산자 적용은 연산자 함수를 호출하는 것과 문법상 동치이다(syntactic sugar). 가령, a+b는 a.operator +(b) 또는 ::operator +(a,b)와 동치라는 뜻이다. 연산자 함수는 표준 규격이 없이 C++ 컴파일러마다 제각각으로 제정한 방식으로 심벌 명칭이 인코딩된다.

앞에서 예를 들었듯이 연산자 함수는 클래스에 소속될 수도 있고, 특정 클래스에 소속되지 않은 중립적인 전역 함수로 선언될 수도 있다. 클래스의 멤버 함수인 경우 this가 선행 피연산자에 포함된 것으로 간주되어 함수의 인자 개수가 하나 줄어든다. 연산자 함수는 명백한 특성상 default 인자를 갖는 게 허용되지 않는다.

한 클래스와 관련된 처리는 당연히 가능한 한 클래스의 내부에서 다 알아서 하는 게 좋다. 그러니 전역 함수 형태의 연산자 함수는 이항 연산자에서 우리 타입이 앞이 아니라 뒤에 나올 때 정도에나 필요하다. 가령, obj+1이 아니라 1+obj 같은 형태일 때 말이다. 그리고 이 전역 함수는 당연히 클래스의 내부에 접근이 가능해야 연산을 수행할 수 있을 것이므로 보통 friend 속성이 붙는다.

연산자가 클래스 함수와 전역 함수 형태로 모두 존재할 경우엔 모호성 에러가 나는 게 아니라 전역 함수가 우선적으로 선택되는 듯하다. a+b에 대해서 ::operator +(a,b)와 a.operator +(b)가 모두 존재하면 전자가 선택된다는 뜻. 언어의 설계가 원래 그렇게 된 건지는 잘 모르겠다.

본인은 연산자 오버로딩을 다음과 같은 양상으로 분류해 보았다.

1. 쉽고 직관적인 오버로딩

(1) 사칙연산, 대입, 비교, 형변환 정도가 여기에 속한다. 기본 중의 기본이다.
사칙연산을 재정의함으로써 C++에서도 문자열을 +를 써서 합치는 게 가능해졌다(동적 메모리 관리까지 하면서). 그리고 복소수, 유리수, 벡터 같은 복합적인 수를 표현하는 자료형을 만들 수 있게 되었다.

(2) 내부적으로 포인터 같은 외부 리소스를 참조하고 있기 때문에 단순 memcpy, memcmp 알고리즘으로는 대입이나 비교를 할 수 없는 클래스의 경우, 해당 연산자의 오버로딩이 필수이다. 이 역시 문자열 클래스가 좋은 예이다. 대입 연산자는 복사 생성자와도 비슷한 구석이 있다.
대입 연산자는 생성자와 역할이 비슷하다는 특성상, 연산자들 중 상속이 되지 않는다. 모든 클래스들은 상속에 의존하지 말고 자신만의 대입 연산자를 갖추고 있어야 한다는 뜻이다. 단, 순수 대입인 = 말고 +=, -= 같은 부류들은 상속됨.

(3) 비교 연산은 같은 클래스에 속하는 여러 원소들을 다른 컨테이너 클래스에다 집어넣어서 순서대로 나열해야 할 때 매우 요긴하게 쓰인다. 전통적인 C 스타일의 비교 함수보다 훨씬 더 직관적이다.

(4) 형변환 연산자야 상황에 따라 어떤 개체의 형태를 카멜레온처럼 바꿔 주고 개체의 사용 호환성을 높여 주는 기능이다. 포인터나 핸들 하나로만 달랑 표시되는 자료형에 대해서 자동으로 메모리 관리를 하고 여러 편의 기능을 수행하는 wrapper 클래스를 만든다면, 본디 자료형과 나 자신 사이의 호환성을 이런 형변환 연산자로 표현할 수 있다.
문자열 클래스는 const char/wchar_t * 같은 재래식 문자 포인터로 자신을 자동 변환하는 기능을 제공하며, MFC의 CWnd도 이런 맥락에서 HWND 형변환을 지원하는 것이다.

(5) 한편, 비트 연산자는 산술 연산보다야 쓰이는 빈도가 아무래도 낮다. 비트 벡터나 집합 같은 걸 표현하는 클래스를 만드는 게 아닌 이상 말이다.
단, 뭔가 직렬화 기능을 제공하는 스트림 개체들이 << >>를 오버로딩한 것은 굉장히 적절한 선택인 것 같다. MFC의 CArchive라든가 C++의 iostream 말이다. input, output이라는 방향성을 표현할 수 있고 또 비트 shift라는 개념 자체가 뭔가 '이동, 입출력'이라는 심상과 잘 맞아떨어지기 때문이다.

다른 연산자들보다 적당히 우선순위가 낮은 것도 장점. 하지만 산술 연산 말고 비교나 비트 연산자는 <<, >>보다도 우선순위가 낮으므로 사용할 때 유의할 필요가 있다.

2. 아까 것보다는 좀 더 생소하고 어려운 오버로딩

(1) 필요한 경우, operator new와 operator delete를 오버로딩할 수 있다. 물론, 객체를 생성하고 소멸하는 new/delete 본연의 기능을 완전히 뒤바꿀 수는 없지만, 이 연산자가 하는 일 중에 메모리를 할당하여 포인터를 되돌리는 기능 정도는 바꿔치기가 가능하다는 뜻이다. C/C++ 라이브러리가 기본 제공하는 heap이 아닌 특정 메모리 할당 함수를 써서 특정 메모리 위치에다가 객체를 배체하고 싶을 때 말이다.

특히 new/delete는 여러 개의 인자를 받을 수 있는 유일한 연산자 함수이다. size_t 형태로 정의되는 메모리 크기 말고 다른 인자도 받는 다양한 버전을 만들어서 new(arg) Object; 형태의 문법을 만들 수 있으며, 이때 arg는 Object의 생성자가 아니라 operator new라는 함수에다 전달된다. (delete 연산자는 어떤지 잘 모르겠다만..;;)

그리고 이놈은 전역 함수와 클래스 멤버 함수 모두 가능하다. 클래스 멤버 함수일 때는 굳이 static을 붙이지 않아도 이놈은 static이라고 간주된다. 당연히.. malloc/free 함수의 C++ 버전인데 this 포인터가 전혀 의미가 없는 문맥이기 때문이다..

사실, C++은 단일 개체를 할당하는 new/delete와 개체의 배열을 할당하는 new []/delete []도 구분되어 있다. 후자의 경우, 생성자나 소멸자 함수를 정확한 개수만치 호출해야 하기 때문에 배열 원소의 개수를 몰래 보관해 놓을 공간도 감안하여 메모리가 할당된다.
그러나 이것은 언제까지나 컴파일러가 알아서 계산을 하는 것이기 때문에 배열이냐 아니냐에 따라서 메모리 할당/해제 함수의 동작이 딱히 달라져야 할 것은 없다. 굳이 둘을 구분해야 할 필요가 있나 모르겠다.

(2) ++, --는 정수나 포인터 같은 아주 간단한 자료형과 어울리는 단항 연산자이다. 그런데 개체의 내부 상태를 한 단계 전진시킨다거나 할 때... 가령, 연결 리스트 같은 자료형에서 iterator를 다음 노드로 이동시키는 것을 표현할 때는 요런 물건이 유용하다. 난 파일 탐색 함수를 클래스로 만들면서 FindNextFile 함수 호출을 해당 클래스에 대한 ++ 연산으로 표현한 적이 있다. ㅎㅎ

++, --는 전위형(prefix ++a)과 후위형(postfix a++)이 둘 존재한다. 후위형 연산자 함수는 전위형 연산자 함수에 비해 잉여 인자 int 하나가 추가로 붙는다. 그리고 후위형 연산자는 자신의 상태는 바꾸면서 바뀌기 전의 자기 상태를 담고 있는 임시 객체를 되돌려야 하기 때문에 처리의 오버헤드가 전위형보다 더 크다. 아래의 코드를 참고하라.

A& A::operator++() //++a. a+=1과 동일하다.
{
    increment_myself();
    return *this;
}

A A::operator++(int) //a++.
{
    A temp(*this);
    increment_myself();
    return temp;
}

보다시피, 전위형과 후위형의 실제 동작 방식은 전적으로 관행에 따른 것이다. 전위형이 후위형처럼 동작하고 후위형이 전위형처럼 반대로 동작하게 하는 것도 프로그래머가 마음만 먹으면 엿장수 마음대로 얼마든지 가능하다.
정수의 경우 프로그래머가 for문 같은 데서 a++이라고 쓴다 해도 똑똑한 컴파일러가 굳이 임시 변수 안 만들고 ++a처럼 최적화를 할 수 있다. 그러나 사용자가 오버로딩한 연산자는 실제 용례가 어찌 될지 알 수 없으므로 컴파일러가 선뜻 최적화를 못 할 것이다.

일반적으로 a=a++의 실행 결과는 컴파일러의 구현 내지 최적화 방식에 따라 들쭉날쭉 달라지는 것으로 잘 알려져 있다. (당장 비주얼 C++와 xcode부터가 다르다!) 또한 a가 일반 정수일 때와, 클래스일 때도 동작이 서로 달라진다. 이식성은 완전히 안드로메다로 간다는 뜻 되겠다. 더구나 한 함수 호출의 인자 안에서 a와 a++이 동시에 존재하는 경우, 어떤 값이 들어가는지는 같은 컴파일러 안에서도 debug/release 빌드에 따라 차이가 생길 수 있으므로 이런 모호한 코드는 작성하지 않아야 한다.

(3) 포인터를 역참조하는 연산자인 * ->는 원래는 포인터가 아닌 일반 개체에 대해서 쓰일 수 없다. 그러나 C++은 이런 연산자를 오버로딩함으로써 인위적인 '포인터 역참조' 단계를 만들 수 있으며, 이로써 포인터가 아닌 개체를 마치 포인터처럼 취급할 수 있다. 형변환 연산자와 비슷한 역할을 하는 셈이다.

C++ STL의 iterator가 좋은 예이고, COM 포인터를 템플릿 형태로 감싸는 smart pointer도 .을 찍으면 자신의 고유한 함수를 호출하고, ->를 찍으면 자신이 갖고 있는 인터페이스의 함수를 곧장 호출하는 형태이다.
-> 연산자는 전역 함수가 아니라 인자가 없는 클래스 멤버 함수 형태로 딱 하나만 존재할 수 있다. 다음 토큰으로는 구조체나 클래스의 멤버 변수/함수가 와야 하기 때문에 리턴 타입은 구조체나 클래스의 포인터만이 가능하다.

(4) []도 재정의 가능하고 심지어 함수 호출을 나타내는 ()조차도 연산자의 일종으로서 재정의 가능하다!
[]의 경우 클래스가 포인터형으로의 형변환을 제공한다면 정수 인덱스의 참조는 컴파일러가 자동으로 알아서 그 포인터의 인덱스 참조로 처리한다. 그러나 [] 연산자 함수는 굳이 정수를 받지 않아도 되기 때문에 특히 hash 같은 자료구조를 만들 때 mapObject[ "H2O" ] = "산소"; 같은 구문도 가능해진다. 단, 진짜 고급 언어들처럼 인자를 여러 개 줄 수는 없다. map[2, 5] = 100; 처럼 할 수는 없다는 뜻.

()도 사정이 비슷한지라, 클래스가 함수 포인터로의 형변환을 제공한다면 굳이 () 연산자를 재정의하지 않아도 개체 다음에 곧장 ()를 붙이는 게 가능은 하다. 그러나 그 함수 포인터는 this 포인터가 없는 반면, () 연산자 함수는 this를 갖는 엄연한 멤버 함수이다.
함수 포인터로의 형변환 연산자 함수는 언어 문법 차원에서 토큰 배열의 한계 때문에 대상 타입을 typedef로 먼저 선언해서 한 단어로 만들어 놔야 한다.

Posted by 사무엘

2013/07/15 08:35 2013/07/15 08:35
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/854

1.

남이 짠 레거시 코드를 업무상 들여다보다가 우연히 발견한 건데,
아래의 코드는 비주얼 C++에서는 컴파일되지만 gcc 계열의 다른 컴파일러에서는 컴파일되지 않는다.

class A {
public:
    class B;
};

class A::B {
public:
    A::B() { }
};

일단, 클래스 내부의 하위 클래스를 저런 식으로 forward 선언했다가 나중에 다시 선언하는 문법 자체를 본인은 직접 구사한 적이 전혀 없었다.
그랬는데, 어찌된 일인지 생성자 함수를 선언할 때 클래스 A까지 다 써 주는 것을 비주얼 C++은 허용하지만 다른 컴파일러들은 그러하지 않았다.
왜 그런지, 이와 관련된 표준 규정은 없는지 궁금하다.

2.

Lyn Tono 님의 블로그를 보다가, 평소에 미처 생각도 못 했던 흥미로운 사실을 발견하여 이곳에다가도 소개하겠다.

void foo() { puts("global function"); }

template<typename T>
class C {
    T m;
public:
    //static이든 아니든 사실 상관은 없음
    static void foo() { puts("Member function"); }
};

template<typename T>
class D: public C<T> {
public:
    void bar() { foo(); }
};

위와 같은 함수와 템플릿 클래스를 선언한 뒤 D<int> q; q.bar(); 라고 실행하면,
비주얼 C++에서는 클래스에 소속된 foo 멤버 함수가 불리지만, 역시 gcc 계열의 다른 컴파일러에서는.. 놀랍게도 global 함수가 불린다!
이건 우선순위 문제도 아닌지라, global 함수를 없앤다고 해서 멤버 함수가 차선으로 지명되지도 않는다. 그 경우에도 클래스 멤버는 존재가 무시되고 그냥 컴파일 에러가 난다. -_-;;

그렇다. 템플릿 클래스는 기반 클래스의 멤버 지명이 비템플릿 클래스처럼 그렇게 쉽게 되질 않는다.
this-> (멤버. 심지어 this 포인터가 존재하지 않는 static 함수이더라도) 혹은 :: (전역)을 명시적으로 써 줘야 한다.

내가 생업을 위한 코딩을 하는데 비주얼 C++을 벗어날 일은 거의 없지만, 저 상황에서 당연히 멤버 함수가 불릴 거라고 예상한 게 다른 컴파일러에서는 그렇지 않을 수도 있다는 걸 염두에 둬야겠다.

3.

예전에 비주얼 C++이 지원하는 for each 문에 대해 소개를 했었고, 친절하게 의견을 남겨 주신 김 진 님으로부터 다른 컴파일러에도 range-based for 문에 대한 비슷한 문법이 존재한다는 보충 설명도 들었다.

그런데 비주얼 C++의 경우, for each() 내부에서 중괄호 없이 if...else문을 썼는데, else부터가 왜 인식이 안 되지? 최신 2012까지도. 아무래도 버그가 아닌지 의심된다. 아래의 예들을 보시라.

for(i=0; i<10; i++) if(a) b(); else c(); //원래 OK

for each(auto i in container) if(a) b(); else c(); //ERROR: 이것만 안 될 이유가 없잖아? 왜? 게다가 인텔리센스 컴파일러는 이를 에러로 지적하지 않음.

for each(auto i in container) { if(a) b(); else c(); } //중괄호를 해 주면 OK

for each(auto i in container) a ? b(): c(); //차라리 이렇게 하는 것도 OK

for(auto i: container) if(a) b(); else c(); //xcode의 이 문법도 당연히 아무 문제 없이 OK. 참고로 비주얼 C++도 2012부터는 for each뿐만 아니라 이 문법도 지원하기 시작했으며, else문에 이상이 없다.

웹 표준만큼이나 C++ 표준도 세밀한 부분에서의 이행 여부가 컴파일러마다 케바케인 것 같다.
옛날엔 IE6이 웹 표준을 안 지킨다고 욕 얻어먹은 것만큼이나 VC6도 C++ 표준 미준수 때문에 많은 비판을 받았었다. for 문 안에서 선언한 변수의 scope 문제가 제일 유명하고 말이다.

하지만 표준 자체가 모호하거나 아예 커버하지 않고 있는 사항이 있다면 그저 묵념..

여담이지만, 2차원 배열을 순회하는 경우 비주얼 C++의 for each는 배열 안의 각 원소를 하나씩 일일이 순회하는 반면, for(:) 문은 각 배열의 포인터를 변수에다 넘겨 주면서 여전히 1차원적으로만 순회한다는 차이도 있다.

4.

C/C++에서 작은따옴표는 문자 상수를 나타낸다. sizeof('a')의 값이 C에서와 C++에서 서로 다르다는 건 이미 잘 알려진 사실. 그런데 작은따옴표 안에 탈출문자가 아닌 일반 문자가 둘 이상 중첩되는 게 문법적으로 가능하며, 에러가 아니다. 그리고 더욱 기괴한 것은, 그렇게 중첩되었을 때 이 문자 상수가 갖는 값은 표준으로 정해져 있지 않고 컴파일러 구현체가 해석하기 마음대로라는 것! 일부러 그렇게 규격을 '미정'으로 남겨 놨다.

대부분의 컴파일러에서는 'ab'를 0x4142라고 합성해서 인식하는 식의 배려는 해 주고 있다. 그러나 이것은 애초에 표준 동작이 아니다 보니, 컴파일러의 기반 아키텍처 또는 코드 생성 대상 아키텍처의 엔디언에 따라 세부적인 동작이 달라진다. 다시 말해 이것은 이식성을 전혀 보장받을 수 없는 지뢰밭 같은 테크닉이며, 그렇기 때문에 IOCCC 같은 대회에서 써먹을 수도 없다.

그럴 거면 둘 이상의 문자는 차라리 깔끔하게 경고나 에러 처리라도 해 주지 하는 아쉬움이 있다.
<날개셋> 한글 입력기의 수식은 문자 상수로 둘 이상의 문자를 집어넣으면 문법 에러로 간주한다.

Posted by 사무엘

2013/04/25 08:38 2013/04/25 08:38
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/822

IOCCC라고, 사람이 가장 알아 보기 힘들고 충공깽스러운 형태로 작성된 C 프로그램 코드를 접수받는 공모 대회가 있다.
단순 코더가 아니라 전산학 내공과 해커 기질이 충만한 레알 베테랑 프로그래머라면 이미 들어서 알 것이다.

입상작들은 내가 보기에 크게 (1) 아스키 아트형, 아니면 (2) 크기 줄이기 암호형이라는 두 갈래로 나뉜다. 대회에 공식적으로 이런 식으로 참가 부문이 나뉘어 있는 건 아니지만, 여기 참가자들이 추구하는 오덕질의 목표가 대체로 이 둘 중 한 갈래로 나뉘기 때문이다.

전자는 영락없이 아스키 문자로 사람 얼굴이나 문자 같은 그림을 그려 놨는데 그건 컴파일 되는 올바른 C 코드이다. 그뿐만이 아니라 그걸 실행하면 기가 막힌 유의미한 결과물이 나온다. 간단한 게임이라든가 원주율값 계산 같은 것부터 시작해 심지어 CPU 에뮬레이터나 간단한 컴파일러, 운영체제까지 들어있는 경우도 있다.

후자는 수단과 방법을 가리지 않고 길이를 줄이기 위해 들여쓰기, 주석, 헝가리언 표기법 따위는 다 쌈싸먹고 진짜 정체를 알 수 없는 이상한 숫자와 기호와 문자로 범벅이 된 코드인데, 빌드해 보면 역시 소스 코드의 길이에 비해 믿을 수 없는 퀄리티의 동작이 나온다. 자바스크립트 같은 코드를 난독화 처리한 것과 비슷한 형태가 된다.

어떤 언어에서 소스 코드 자신을 출력하는 프로그램을 콰인(Quine)이라고 부른다. GWBASIC이라면 언어에 LIST라는 명령이 있으니 쉽겠지만, 일반적인 컴파일 기반 언어에서는 그걸 만드는 게 보통일이 아니다. 그런데 이 IOCCC 대회 입상작 중에는 A라는 코드가 있는데 그걸 실행하면 B라는 소스 코드가 출력되고, B를 빌드하여 실행하면 C라는 소스 코드가 나오고, 다음으로 C를 빌드하면 다시 A가 나오는... 중첩 콰인을 실현한 충격과 공포의 프로그램도 있었다. 그것도 A, B, C는 다 형태가 완전히 다르고 인간이 인식 가능한 아스키 아트! Don Yang이라는 사람이 만든 2000년도 입상작이다.

역대 수상작들을 보면 프로그래머로서 인간의 창의력과 잉여력, 변태스러움이 어느 정도까지 뻗칠 수 있는지를 알 수 있다. 그리고 이런 대회는 한 프로그래밍 언어의 극악의 면모를 시험한다는 점에서 전산학적으로도 나름 의미가 있다. 들여쓰기와 긴 변수명과 풍부한 주석이 갖춰진 깔끔한 코드든, 저런 미친 수준의 난독화 코드든 컴파일러의 입장에서는 어차피 아무 차이 없는 똑같은 코드라는 게 아주 신기하지 않은가?

다른 언어가 아니라 C는 시스템 레벨에서 프로그래머의 권한이 강력하다. 그리고 전처리기를 제외하면 특정 공백 문자에(탭, 줄바꿈 등) 의존하지 않는 free-form 언어이며, 언어 디자인 자체가 온갖 복잡한 기호를 좋아하는 오덕스러운 형태인 등, 태생적으로 난독화에 유리하다. 게다가 도저히 C 코드라고 볼 수 없을 정도로 코드의 형태와 의미를 완전히 엉뚱하게 뒤바꿔 버리는 게 가능한 매크로라는 비장의 무기까지 있다!

심지어는 C++보다도 C가 유리하다. 함수를 선언할 때 리턴 타입을 생략하고 함수 정의에서는 리턴 문을 생략할 수 있다. 가리키는 대상 타입이 다른 포인터를 형변환 없이 바로 대입할 수 있으며, 또한 인클루드를 생략하고 표준 함수를 바로 사용할 수도 있다. C++이었다면 바로 에러크리이지만, C에서는 그냥 경고만 먹고 끝이니 말이다. C의 지저분한 면모가 결국 더 짧고 알아보기 힘든 코드를 만드는 데 유리하다는 뜻 되겠다.

현업에서는 거의 언제나 C++만 써 와서 잘 실감을 못 했을 뿐이지, C는 우리가 생각하는 것보다 저 정도로 꽤 유연(?)한 언어이긴 하다. IOCCC 참가자의 입장에서 C++이 C보다 언어 구조적으로 더 유리한 건, 아무데서나 변수 선언을 자유롭게 할 수 있다는 것 정도일 것이다.

그러나 겨우 그 정도로는 불리한 점이 여전히 유리한 점보다 더 많은 것 같다. 생성자와 소멸자, 오버로딩, 템플릿 등으로 더 알아보기 힘든 함축적인 코드를 만드는 건 상당한 규모가 있는 큰 프로그램에서나 위력을 발할 것이고, 긴 선언부의 노출이 불가피하여 무리일 듯.

옛날에는 대회 규정의 허를 찌른 엽기적인 꼼수 작품도 좀 있었다.
이 대회는 1984년에 처음 시작되었는데, 그때 입상작 중에는 main 함수를 함수가 아니라 기계어 명령이 들어있는 배열로 선언해 놓은 프로그램이 있었다(1984/mullender). 이건 기계 종류에 종속적일 뿐만 아니라 요즘 컴파일러에서는 링크 에러이기 때문에, 그 뒤부터는 대회 규정이 바뀌어 이식성 있는 코드만 제출 가능하게 되었다.

그리고 1994년에는 콰인이랍시고 0바이트 소스 코드가 출품되었다(1994/smr). 소스가 0바이트이니, 아무것도 출력하지 않아도 콰인 인증..;; 이건 충분히 참신한 덕분에 입상은 했지만 그 뒤부터는 역시 소스 코드는 1바이트 이상이어야 한다는 규정이 추가되었다. 빈 소스 파일을 빌드하려면 빌드 옵션도 좀 미묘하게 변경을 해야 했다고 한다.

이런 코드를 작성하기 위해서는 모든 변수와 함수를 한 글자로 표현하는 것부터 시작해서 평범한 계산식을 온갖 포인터와 비트 연산자로 배배 틀기, 숫자 테이블 대신 문자열 리터럴을 배열로 참고하기(가령, "abcd"[n]) 같은 건 기본 중의 기본 테크닉이다. 그리고 그걸 아스키 아트로 바꾸는 능력이라든가, 원래 오리지널 프로그램을 기가 막히게 짜는 기술은 별개이다. 이런 코드를 만드는 사람은 정말 코딩의 달인 중의 달인이 아닐 수 없다.

이 대회는 전통적으로 외국 해커 덕후들의 각축장이었다. 그러나 지난 2012년도 대회에서는 자랑스럽게도 한국인 입상자가 한 명 배출되었는데, 본인의 모 지인이다. 그가 출품한 프로그램은 영어로 풀어 쓴 숫자를 입력하면(가령, a hundred and four thousand and three hundred and fifty-seven) 그걸 아라비아 숫자로 바꿔 주는 프로그램(104357). 코드를 보면 저게 어딜 봐서 숫자 처리 프로그램처럼 생겼는가. -_-

코드를 대충 살펴보면, long long이 바로 등장하는 데서 알 수 있듯, 나름 32비트 범위를 벗어나는 큰 자리수까지 지원한다. 문자열 리터럴을 배열로 참고하는 것도 곧바로 쓰였음을 알 수 있다.
그리고 옛날의 C 시절에 허용되었던 관행이었다고 하는데, 함수의 인자들을 아래와 같은 꼴로 선언하는 게 이 대회 출품작에서는 종종 쓰인다고 한다.

int func(a,b) int a, char *b; { ... }

하긴, C/C++이 기괴한 면모가 자꾸 발견되는 건 어제오늘 일이 아니다.
a[2]뿐만이 아니라 2[a]도 가능하다든가,
#include 대상으로 매크로 상수도 지정 가능하다든가,
C++의 default argument로 0이나 -1 같은 것뿐만 아니라 사실은 아예 함수 호출과 변수 지정도 가능하다는 것..
switch문의 내부에 for 같은 다른 반복문이 나온 뒤에 그 안에 case가 있다던가..;;

정말 약 빨고 만든 언어에다 약 빨고 코딩한 개발자라고밖에 볼 수 없다.
나로서는 범접할 수조차 없는 이상한 프로그래밍 대회에 한동안 엄청 관심을 갖더니 결국 입상해 버린 그의 오덕력에 경의를 표한다. 그저 놀라울 뿐이다. 이 정도로 소개하고 띄워 줬으니, 그분이 이 자리에 댓글로 소환되는 걸 기대해 보겠다. 아무래도 한국인 다윈 상 수상자가 배출된 것보다는 훨씬 더 자랑스러운 일을 해낸 친구이지 않은가. ㄲㄲㄲㄲㄲㄲㄲ

뭐, 입상했다고 당장 크게 부와 명예가 뒤따르는 건 아니겠지만, 팀장이나 임원이 IOCCC에 대해서 아는 개발자 출신인 회사에 지원할 때 “나 이 대회 입상자요!”라고 이력서에다 써 넣으면 그 이력서의 메리트는 크게 올라갈 수밖에 없을 것이다. 실제로 IOCCC 같은 잉여로운 대회에 참가하는 geek 중에는 구글, MS급 회사 직원도 있고, 사실 이런 대회에 입상할 정도의 guru급 프로그래머가 일자리를 못 구해 걱정할 일은 절대 없을 테고 말이다.

이런 대회에 더 관심 있으신 분은, IOCCC의 국내 저변 확대를 위해 애쓰고 있는 저 친구의 소개 페이지를 참고하시기 바란다.

Posted by 사무엘

2013/04/10 19:20 2013/04/10 19:20
, , , ,
Response
No Trackback , 6 Comments
RSS :
http://moogi.new21.org/tc/rss/response/816

지금으로부터 거의 3년 전, 이 블로그가 개설된 지 얼마 되지 않았던 시절에 본인은 C++의 매우 기괴-_-한 문법인 다중 상속멤버 포인터(pointer-to-member)에 대해서 제각각 따로 글로 다룬 적이 있었다.
이제 오늘은, 그 기괴한 두 물건이 한데 합쳐지면 언어의 디자인이 얼마나 더 흉악해지는지를 보이도록 하겠다.
그 내력을 알면, C++ 이후의 객체지향 언어에서 다중 상속이 왜 봉인되어 버렸는지를 이해할 수 있을 것이다. 뭐, 이미 다 아는 분도 있겠지만 복습 차원에서.

클래스의 멤버 포인터는 그 가리키는 대상이 변수이냐 함수이냐에 따라서 내부 구조가 크게 달라진다는 말을 예전에 했었다. 함수일 때는 포인터답게 말 그대로 실행될 함수의 메모리 위치를 가리키지만, 변수일 때는 이 멤버가 this로부터 얼마나 떨어져 있는지를 나타내는 정수 오프셋에 불과하다. &POINT::x 는 0, &POINT::y는 4 같은 식.
그래서 비주얼 C++은 x64 플랫폼에서도 단순 클래스의 멤버 변수 포인터는 뜻밖에도 8바이트가 아닌 4바이트로 처리한다. UNT_PTR이 아니라 그냥 unsigned int라고 본 것이다.

그런데 다중 상속이 동반된 클래스는 '단순' 클래스라고 볼 수가 없어지며, 그런 클래스를 대상으로 동작하는 멤버 포인터는 내부 메커니즘이 굉장히 복잡해진다. 멤버 변수야 오프셋이 바뀌니까 그렇다 치지만, 멤버 함수의 포인터도 데이터 오프셋의 영향을 받는다. 비록 함수 자체는 오프셋을 타지 않고 고정된 메모리 주소이긴 하지만, 멤버 포인터가 어느 함수를 가리켜 부르느냐에 따라 그때 그때 this 포인터를 잘 보정해서 줘야 하기 때문이다.

다음 코드를 생각해 보자.
참고로, class 대신 struct를 쓴 이유는 public: 을 따로 써 주는 귀찮음을 해소하기 위해서일 뿐이다. (C#은 struct와 class의 용도가 구분되어 있는 반면, C++은 전혀 그렇지 않으므로.)

struct B {
    int valB; void functionB() { printf("functionB: %p\n", this); }
};
struct C {
    int valC; void functionC() { printf("functionC: %p\n", this); }
};

struct D: public B, public C {
    int valD; void functionD() { printf("functionD: %p\n", this); }
};

그 뒤,

D ob;
void (D::*fp)();
printf("this is %p\n", &ob);
printf("sizeof pointer-to-member is %d\n", sizeof(fp));

fp = &D::functionB; (ob.*fp)();
fp = &D::functionC; (ob.*fp)();
fp = &D::functionD; (ob.*fp)();

코드를 실행해 보면, 놀라운 결과를 볼 수 있다.
이제 fp의 크기가 포인터 하나의 크기보다 더 커졌다.
비주얼 C++ 기준으로, '포인터+int'의 합이 된다. 그래서 x86에서는 8바이트, x64에서는 12바이트.

게다가 중요한 건, functionC를 실행했을 때만 this의 값이 달라져 있다는 것이다.
이건 뭐 다중 상속의 특성상 어쩔 수 없는 면모이며, 멤버 함수를 ob.functionC()라고 직접 호출할 때는 컴파일러가 알아서 처리해 주는 기능이긴 하다.
하지만, 직접 호출이 아니라 멤버 포인터를 통한 간접 호출을 할 때는 이걸 어떻게 구현해야 할까?

결국은 멤버 함수 포인터 자체에 추가 정보가 들어갈 공간이 있어야 하고, 그 정보는 포인터에다가 함수에 대한 대입이 일어날 때 implicit하게 따로 공급되어야 한다.
다중 상속을 받은 클래스의 멤버 함수를 가리키는 포인터는 this 보정을 위한 정수 오프셋이 내부적으로 추가된다. 이제 fp는 단일 포인터 변수라기보다는 구조체처럼 바뀌었다는 뜻이다.

이 fp에다가 functionB나 functionD를 대입하면 그 멤버 함수의 주소만 대입되는 게 아니라, 숨겨진 오프셋 변수에다가도 0이 들어가며(보정할 필요가 없으므로), functionC를 대입하면 그 주소와 함께 오프셋 변수에다가도 0이 아닌 값이 같이 대입된다. 그리고 실제로 fp 호출을 할 때는 this 포인터에다가 보정이 된 값이 함수로 전달된다.

이야기는 여기서 끝이 아니다. 설상가상으로 가상 상속까지 추가된다면?
내가 클래스를 A가 아니라 B에서부터 시작한 게 이것 때문이다. 맨 앞에다가 드디어 다음 코드를 추가하고,

struct A {
    int valA;
    void functionA() { printf("functionA: %p\n", this); }
};

앞에서 썼던 B와 C도 A로부터 가상 상속을 받게 고쳐 보자.

struct B: virtual public A { ... }
struct C: virtual public A { ... }

이것도 물론 추가하고.

fp = &D::functionA; (ob.*fp)();

이렇게 해 보면..
비주얼 C++ 기준 fp의 크기는 더욱 커져서 '포인터+정수 2개' 크기가 된다. x86에서는 12바이트, x64에서는 16바이트.
다중 상속만 있을 때는 함수 말고 변수의 멤버 포인터는 크기가 변함없었던 반면, 가상 상속이 가미되면 변수 멤버 포인터도 이렇게 '크기 할증'이 발생한다. 대입 연산이나 함수 호출 때 몰래 같이 발생하는 일도 더욱 많아지며, 이 현상을 좀 유식하게 표현하면 cost가 커진다.

그 이유는 어렴풋이 유추할 수 있을 것이다. 가상 상속이라는 건 말 그대로 기반 클래스의 오프셋이 클래스의 인스턴스별로 동적으로 변할 수 있다는 뜻이다. this 포인터 보정이 뒷부분 파생 클래스의 정확한 위치를 파악하기 위해서 발생하는 일이라면, 가상 상속 보정은 앞부분 기반 클래스의 위치를 파악하는 것이 목적이다.

이런 사정으로 인해 functionA()도 원래 개체의 주소와는 다른 주소를 받으며, 이것은 functionC()가 받는 주소와는 또 다르다.
다만, pointer-to-member는 가상 함수와는 기술적으로 전혀 무관하게 동작하기 때문에, 가상 함수가 존재하는 클래스라고 해서 오버헤드가 추가되는 건 없다. 함수 멤버 포인터로 가상 함수를 가리키면, 아예 가상 함수 테이블을 참조하여 진짜 함수를 호출하는 wrapper 함수가 따로 만들어져서 그걸 가리키고 있게 된다.

요컨대 비주얼 C++은 단순 클래스, 다중 상속만 있는 클래스, 거기에다 가상 상속까지 있는 클래스라는 세 등급에 따라 멤버 포인터를 관리한다. 다만, 함수가 아닌 변수 멤버 포인터는 가상 상속 여부에 따라 두 등급으로만 나누는 듯하다. 이 정도면, 이 글을 쓰는 본인부터 이제 머리가 핑그르르 도는 것 같다.

이제 마지막으로 생각해 볼 문제가 있다. C++은 클래스의 명칭 선언만 하는 게 가능하다는 점이다.

class UnknownBase;
class UnknownDerived;

굳이 클래스의 몸체를 몰라도 이 클래스에 대한 포인터 정도는 선언이 가능하다. 그렇기 때문에 명칭 선언은 컴파일 때 헤더 파일간의 의존도를 줄이고 모듈간의 독립성을 높일 때 요긴하게 쓰이는 테크닉이다.
다만, 여러 클래스들을 명칭 선언만 하면 이들간의 상속 관계도 아직 밝혀지지 않기 때문에, 실질적인 기반 클래스와 파생 클래스 사이에 암시적인 형변환이나 static_cast, dynamic_cast 따위를 쓸 수 없다는 점도 주의해야 한다.

게다가 이렇게 명칭만 달랑 선언된 클래스에 대해서 멤버 포인터를 선언하면..
컴파일러는 이 클래스가 다중 상속이 존재하는지, 가상 상속이 존재하는지 같은 걸 알지 못한다!
그렇다고 무식하게 에러 처리하며 멤버 포인터의 선언을 거부할 수도 없는 노릇이니,
컴파일러는 가장 보수적으로 이 클래스가 어려운 요소들은 모두 갖추고 있을 거라고 생각하고 가장 덩치 크고 복잡한 등급을 선택할 수밖에 없다.

나중에 사용자가 추가 인클루드를 통해 클래스의 몸체를 선언하여, 이 클래스는 단순한 놈이라는 게 알려지더라도 한번 복잡하게 결정되어 버린 타입 구조는 다시 바뀌지 않는다.
게다가 이렇게 unknown 클래스에 대한 멤버 포인터는 단순히 '가상 상속 클래스' 등급이 아니라, 메타 정보가 추가로 붙는지 비주얼 C++에서는 함수 기준으로 x86에서 무려 16바이트를 차지하며, x64에서는 24바이트를 차지하게 된다. 포인터 둘, int 둘의 합이다.

printf("%d\n", sizeof(void (UnknownDerived::*)() ));

물론, 멤버 포인터부터가 굉장한 레어템인데, 몸체도 없이 명칭 선언만 된 클래스에 대해서 멤버 포인터를 덥석 들이대는 코딩을 우리가 실생활에서 직접 할 일은 극히 드물다. 하지만 딱 machine word와 동일한 크기를 기대했던 멤버 함수 포인터가 3~4배 크기로 갑자기 뻥튀기되고 생각도 못 했던 오버헤드가 추가되는 일은 없어야 하겠기에, 비주얼 C++은 역시 비표준 확장을 통해서 이 문제에 대한 해결책을 제시하고 있다.

그것은 바로 _single_inheritance, _multiple_inheritance, _virtual_inheritance라고 참 길게도 생긴 키워드.
클래스를 명칭 선언만 할 때

class _single_inheritance UnknownDerived;

이런 식으로 써 줌으로써 “이놈은 다중 상속 같은 귀찮은 요소가 없는 클래스이다. 따라서 얘에 대한 멤버 포인터는 추가 오프셋이 없는 제일 간단한 등급으로 만들어도 OK다”라는 힌트를 컴파일러에다 줄 수 있다.
복잡한 놈이라고 예고를 해 놓고 단순한 형태로 클래스를 선언하는 건 괜찮으나, 간단한 놈이라고 예고를 해 놓고 나중에 다중 상속이나 가상 상속을 쓰면 물론 컴파일 에러가 발생하게 된다.

아마 스타크래프트를 만든 사람도 스탑 럴커 같은 전술은 생각을 못 하지 않았을까.
저런 판타지 같은 면모는 C++을 설계한 사람이나 추후에 기능을 확장한 표준 위원회 사람들도 생각을 못 했을 가능성이 높아 보인다.
그렇기 때문에 비주얼 C++처럼 단순, 다중, 가상으로 세 등급을 나눠서 포인터에다 할증 제도를 넣고, 관련 예약어까지 추가한 건 전적으로 표준이 아니라 컴파일러 구현하기 나름이다.

아, 자세한 건 이 사이트 내용을 좀 공부하고 글을 쓰려고 했는데 도저히 다 읽을 엄두가 안 난다.
관심 있는 분들은 알아서 탐독해 보시길.
언뜻 보니, 다중 상속이 멤버 포인터보다 시기적으로 나중에 등장했다. 그래서 둘을 한꺼번에 구현하는 게 이 정도로 복잡하게 꼬인 셈이다.

Posted by 사무엘

2013/04/02 08:33 2013/04/02 08:33
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/813

C/C++의 const 이야기

1.

C/C++에서 const라는 키워드는 어떤 변수를 선언할 때 타입과 함께 지정해 줄 수 있는 modifier 속성이다. 이와 비슷한 위상인 키워드로 volatile도 있다.

이 const의 큰 의미와 용도는 C와 C++에서 모두 동일하다. 바로, 한번 값이 정해지고 나면 그 뒤로 값이 또 바뀔 수 없다는 걸 뜻한다. 비슷한 용도로 쓰이는 매크로 상수나 enum과는 달리, const 개체는 엄연히 상수 역할을 하는 '변수'이기 때문에 L-value의 특성도 껍데기나마 지니며, 자기 주소를 & 연산자로 얻을 수 있다는 특징이 있다. (자기 주소가 있는데 왜 대입을 못 하니 ㄲㄲㄲ)

그런데 const라는 의미를 언어 차원에서 실현하는 방식이 C는 다소 느슨한 편이다.
C 언어도 const 변수에다가 대놓고 대입 연산자를 들이대는 시도 정도는 컴파일러가 에러로 대응하며 막아 준다. 그러나 강제로 const 속성을 없애는 형변환+포인터 연산 같은 것까지 저지하지는 못한다.

이는 마치, C/C++ 코드에서 변수를 초기화하지 않고 사용하는 걸 간단한 지역 변수 정도는 컴파일러가 알아서 발견하여 경고로 처리해 주지만, 복잡한 배열이나 포인터, 구조체의 경우를 일일이 체크하지는 못하는 것과 비슷한 맥락. 그래서

const int MARK = 100;
const int *p = &MARK;

printf("%d %d\n", MARK, *p);
*const_cast<int *>(&MARK) = 50;
printf("%d %d\n", MARK, *p); //이것이 문제.

이런 코드를 돌려 주면 C에서는 MARK가 처음에는 100이다가 나중에는 50이 되어 버린다! 이런 이유로 인해 C에서 const int는 껍데기만 const이지 case 문의 상수로 쓰이지도 못한다. 아, C 언어는 const_cast라는 연산자가 없으니, 그냥 *((int *)&MARK) = 50; 이라고 해야겠지만 말이다.

허나, C++은 이 정책이 바뀌어서 const를 다루는 방식이 좀 더 엄밀해졌다. 사실, 객체 지향 언어이다 보니 상수값을 취급하는 방식이 더 정확하고 엄밀해져야만 하는 게 마땅하다. 무작정 C 같은 '고수준 어셈블리' 패러다임만 추구해서는 곤란할 터이다.

C++은 MARK 변수가 차지하는 메모리에 들어있는 값과 상관없이 소스 코드에서 MARK가 그대로 쓰인 곳은 언제나 100을 대응시켜 준다. 다시 말해 위의 경우 100과 50이 출력된다. MARK와 *(&MARK)의 값이 달라지는 한이 있더라도 MARK는 언어 차원에서 처음 선언해 준 값이 그대로 유지되며, 진짜 매크로 상수처럼 쓰일 수 있다는 뜻이다. 신기하지 않은가? C와 C++ 사이의 교묘한 차이 중 하나이다. C/C++ 프로그래머라면 이 정도는 이미 아는 분이 많을 것이다.

2.

C/C++은 잘 알다시피 '선언 따로, 정의 따로'라는 좀 원시적이라면 원시적인 디자인 철학을 따르는 언어이다. 그래서 헤더에 들어간 선언은 그 선언을 사용하는 모든 번역 단위들이 include를 “매번” 해 줘야 하고, 그 선언에 대한 정의는 아무 번역 단위에다가 “한 번만” 써 주면 링크 때 알아서 말 그대로 '연결'이 된다. 그렇다, 걔네들은 원래 그런 언어이다.

자바나 C#은 클래스의 선언과 정의가 일심동체이고 그 클래스가 곧 번역 단위이다. 뭐, C++도 클래스를 선언하면서 멤버 함수의 몸체까지 헤더 파일 안에다 같이 써 주는 게 불가능하지는 않지만, 그건 간단한 인라인 함수를 만들 때에나 제한적으로 쓰이는 관행이다. 아니면 어차피 모든 클래스의 몸체가 헤더에 들어가야만 하는 템플릿일 때 정도.

자, 이런 이중적인 구조로 인해 C++은 static 멤버 변수의 정의조차도 클래스의 선언과 동시에 할 수가 없다.
여러 번역 단위에서 매번 인클루드되는 '선언부'에다가 한 번만 등장해야 하는 '정의부'가 동시에 들어갈 수는 없기 때문이다.
자바나 C#은 클래스 안에다가 static int MAX = 100; 같은 문장을 아무렇지도 않게 넣을 수 있으나, C++은 굳이 static int MAX;int CFoo::MAX = 100; 을 분리해서 써 줘야 한다.

그럼, C++의 클래스에서 멤버를 선언할 때 대입 연산자가 들어갈 일이란 오로지 순수 가상 함수를 선언할 때 쓰이는 = 0밖에 없는 걸까? (자바와 C#은 순수 가상 함수는 오히려 pure이나 abstract 같은 키워드를 따로 써서 표현함!)

놀랍게도 그렇지는 않다.
딱 하나 예외적으로, static const라는 속성을 지닌 간단한 '정수 계열'의 멤버는 클래스 안에다 선언과 함께 초기화를 하는 게 가능하다. 즉, 클래스 안에다가 static const int MAX = 100; 정도는 C++도 허용해 준다는 뜻이다.

물론 제약이 몹시 심하다.
static과 const 중 속성이 하나라도 빠져서는 안 된다. 그리고 배열이나 구조체의 초기화는 어림도 없다. static const WCHAR NAME[] = L"foo"; 같은 거 안 된다.

쉽게 말해 정수 정도면, 심벌이 있는 곳의 메모리 주소를 참고하는 게 아니라 심벌의 값 자체를 매번 집어넣어 주는 게 어차피 이득이니까 예외적으로 클래스 내부에서의 정의와 초기화가 허용되는 셈이다. 그러니 static const 정수는 그냥 메모리 주소를 얻는 게 가능한 enum 수준에 불과하다.

정수 계열은 심지어 __int64도 허용되지만 포인터는 허용되지 않는다. 그리고 부동소수점도 안 된다. static const double PI = 3.141592; 는 안 된다는 뜻이다. 이건 현재 GNU 계열 컴파일러에서만 지원하는 extension일 뿐, 표준은 아니다.

3.

한 소스 파일에다가 const 속성을 가진 커다란 정수 테이블 배열을 전역변수 형태로 만들었다. 그건 난수표가 될 수도 있고 time-critical한 실시간 계산 프로그램(게임이라든가)에서는 삼각함수나 로그값 테이블이 될 수도 있고 문자 코드 변환 테이블이 될 수도 있다.

그런데 다른 번역 단위에서는 그 테이블의 명칭을 extern으로 선언해 놓고 참고하여 사용했는데, 링크할 때는 그 명칭을 찾을 수 없다고 에러가 나는 것이었다. 본인은 그 이유를 알 수 없었다. 경험적으로 const 속성을 제거하면 문제를 피해 갈 수 있긴 했으나, 값을 변경하지 않는 상수 테이블을 일반 배열로 취급할 수도 없는 노릇이었다.

링크가 되지 않던 이유를 난 한참 뒤에야 알게 됐다.
C가 아닌 C++에서는 static이나 extern 명시가 없이 const로 선언된 전역변수는 기본적으로 extern이 아니라 static 속성이 부여된다. 그러니 그 번역 단위 내부에서만 쓸 수 있지, 외부로 명칭이 노출되지 않으며, 따라서 링크 에러가 난다.

왜 그렇게 정책이 바뀌었냐 하면 const 개체에 대해서는 이 글의 1번 항목에서 명시한 것과 같은 무결성을 보장하기 위해서인 듯하다.
심벌이 가리키는 메모리 주소는 값이 언제 바뀌어 있을지 모르니, const 개체의 값은 매 번역 단위마다 컴파일러가 소스 코드로부터 읽어들여서 확인하기 위해서이다.

이 조치를 무시하고 const 개체의 값을 다른 번역 단위에서도 사용하려면 extern을 명시적으로 지정해 줘야 한다.

extern const TYPE TABLE = ... 라고 바로 써 줘도 되고, external const TYPE TABLE; 이라고 먼저 선언만 한 뒤에 나중에 const TYPE TABLE = ... 을 쓰면 TABLE은 여느 전역변수와 마찬가지로 다른 번역 단위에서 참조가 가능한 extern 변수가 된다.

4.

Windows 환경에서 개발을 하다 보면 지금 설치되어 있는 운영체제의 SDK에 기본 내장되어 있지 않은 GUID를 수동으로 추가해서 사용해야 할 때가 있다.

GUID는 코드가 아니라 128비트짜리 난수가 들어있는 구조체에 불과하지만, 엄연히 const 전역변수들의 집합이기 때문에 선언부와 정의부가 따로 있다. 그리고 주요 GUID의 실제 값들은 플랫폼 SDK의 라이브러리 디렉터리에 있는 uuid.lib에 들어있다. kernel32, user32, gdi32만큼이나 딱히 우리가 지정을 안 해도 자동으로 링크되는 기본 라이브러리이기 때문에, 파일의 존재감을 모르는 분도 많을 것이다.

그런데 이놈의 GUID 하나 좀 쓰자고 헤더 파일과 소스/라이브러리 파일을 다 구비해 줘야 하는 걸까? 여간 번거로운 일이 아닐 수 없다. 귀찮다고 헤더 파일에다가 GUID 값을 몸체(정의)를 다 써 주면, 이론적으로는 그 헤더를 인클루드하여 사용하는 모든 번역 단위에 동일한 GUID의 몸체들이 obj 파일 내부에 중복 기재될 위험이 있기 때문이다.

결국 이 문제는 MS 컴파일러의 경우 자기만의 언어 확장을 만듦으로써 우격다짐으로 해결했다. DLL 심벌을 만들거나 사용할 때 __declspec(dllexport/dllimport)를 사용하는 것처럼 __declspec(selectany)라는 속성도 있다. 이것이 지정된 전역 변수는 여러 object에서 중첩 기재된 심벌이라도 링크 때 딱 한 몸체만 임의 선택된다.

여러 소스 코드에서 공통으로 쓰이는 GUID를 새로 추가하고 싶으면 #include <initguid.h>를 해 준 뒤, DEFINE_GUID 매크로로 새 GUID의 명칭과 값을 써 주면 된다. 이 매크로는 내부적으로 selectany 지정자를 사용한다.

결국 이것은 전역 변수 선언계의 #pragma once나 마찬가지이다. 중복 인클루드 방지에 이어 심벌 몸체의 중복 링크 방지 마크이다. 이게 다 C/C++에는 간편히 끌어다 쓰는 패키지 개념이 없이, 원시적인 헤더/라이브러리에만 의존하느라 컴파일러 제조사가 부득이 추가한 꼼수인 셈이다.

내가 늘 느끼는 거지만..
C++ 님 좀 짱이다. 10년이 넘게 파 왔지만 아직도 지금까지 몰랐던 사실들이 계속 발견된다.

Posted by 사무엘

2013/03/22 08:29 2013/03/22 08:29
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/809

템플릿 인자로 또 템플릿 타입을 받는 타입의 변수 선언이

A<B<C > > d;
이런 식으로 돼 있는 옛날 C++ 코드를 보니 문득 감회가 새롭다.

예전에는 템플릿 인자를 닫는 > 가 중첩될 때, 여러 >를 >>로 붙일 수가 없었다.
타입 선언인지 일반 연산인지 문맥을 고려하지 않는 전통적인 parser는, 이것을 비트 shift 연산자로 인식하기 때문이었다. 따라서 오류크리.
그래서 > 사이를 강제로 띄워 줘야 했는데 이것이 보기에 그리 좋지는 않음이 자명한 노릇이었다.

일단 C++ 계보의 언어들은 문법 차원에서 변수 선언을 명시하는 토큰이 없고(파스칼의 var과 콜론, 베이직의 Dim과 as 같은), 달랑 “타입 변수명”이라는 아주 문맥 의존적인 문법만을 바탕으로 변수 선언을 컴파일러가 알아서 추론해야 하기 때문에 파싱이 까다로운 게 사실이다. 게다가 C++부터는 변수 선언은 객체 선언과 동급이 되어, 함수 몸체 내부 어디에서나 마음대로 올 수 있지 않은가.

훗날 C++ 언어가 C++11로까지 확장되면서, 언어가 명시하는 스펙 자체가 바뀌면서 >>를 붙여 써도 괜찮게 되었다.
비주얼 C++의 경우, 2003은 >>가 확실하게 인식되지 않았는데, C++11이 정식으로 제정되기 전부터 2008쯤부터 이미 >>를 지원하고 있었다.

이런 문법의 변화로 인해, 클래스 A는 type을, 클래스 B는 int를 받는 템플릿 클래스라고 했을 때

A<B<30>>1> > p;

라는 코드가 과거에는 30>>1이 15라고 계산되어 컴파일이 되었지만, 이제는 되지 않는다. >>가 템플릿 인자를 닫는다는 의미로 먼저 인식되었기 때문이다. 이것은 함수 호출 문맥에서는 ,가 콤마 연산자가 아니라 인자 구분자로 먼저 인식되는 것과 비슷한 맥락이다.
바뀐 문법에서는

A<B<(30>>1)>> p;

라고, 뒤의 >를 붙일 수 있는 대신 진짜 템플릿 인자 내에서의 산술 연산은 괄호로 싸 줘야 <, > 사이의 모호성을 막을 수 있다.
사실, 템플릿 인자 안의 숫자는 어차피 컴파일 시점에서 값이 다 결정되는 것들이기 때문에, 복잡한 연산이 들어갈 일은 거의 없다. 산술 연산을 괄호로 반드시 싸야 하게 만들고 그 대신 템플릿 인자의 < >에 편의를 더 주는 것이 훨씬 더 합리적인 정책인 것이 사실이다.

뭐, 괄호도 해 주고 >를 띄워 주기까지 하면, 어느 구닥다리 C++ 컴파일러에서나.. 컴파일 가능한 코드를 만들 수 있긴 하지만, 미관은 제일 떨어지겠지. ㅋㅋ

그러고 보니 옛날에는 일반 함수 포인터 말고, C++ 멤버 함수 포인터를 명시할 때 그냥 이름만 써 줘도 괜찮은 수준이었는데
나중에는 반드시 &를 붙이고 scope도 명시해 줘야 하게 문법이 좀 더 엄격하게 바뀐 걸로 기억한다. 한 VC++ 2005쯤부터이다. for(int x=0; ... )에서 x의 scope만큼이나 전형적인 호환성 문제이다.

이렇듯 C++이 어제나 오늘이나 큰 뼈대는 변함없고 계속 새로운 기능이 추가만 되는 것 같아도,
이미 있던 문법도 야금야금 바뀌어 온 게 좀 있다.

Posted by 사무엘

2012/12/10 08:30 2012/12/10 08:30
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/767

C++의 템플릿에서 인자로 쓰이는 것은 정수 아니면 자료형이다. 자료형은 class 또는 typename으로 명시해 줄 수 있으며, 이 자료형 인자는 (1) 클래스 내부의 멤버 변수의 자료형, 또는 (2) 멤버 함수의 인자나 리턴값의 자료형으로 쓰일 수 있다.

template<typename T>
class Foo {
public:
    T Bar;
};

그러니 위와 같이 생긴 클래스는 Foo<int>, Foo<char *>, Foo<RECT> 등 여러 형태로 활용할 수가 있는데,
이건 뭐지..?

Foo<int(PCSTR)> f;

이것은 int (*)(PCSTR)처럼 함수의 포인터를 지정한 것도 아니고, 일반적인 상황에서는 있을 수 없는 타입 문자열이다.
이것은 템플릿 인자에서만 허용되는 문법인데, 클래스의 멤버 함수의 프로토타입을 지정한 것이다. 이렇게 선언된 클래스에서는 Bar가 멤버 변수가 아니라 아래와 같이 호출 가능한 멤버 함수가 된다! 클래스의 형태가 완전히 달라지게 된다.

int x = f.Bar("hello, world!");

물론, Bar 함수의 몸체는 사용되는 템플릿 인자별로 모두 정의를 해 줘야 한다. 안 그러면 링크 에러가 난다.

template<>
int Foo<int(PCSTR)>::Bar(PCSTR p)
{
    return (int)p;
}

결국 멤버 함수의 인자와 리턴값이 템플릿의 인자에도 들어가고 함수 자체에도 중복 기재되는 셈이다.

Bar에 대해서 f.Bar()처럼 함수 호출이 가능하려면 Bar는 ()연산자가 오버로드되어 있는 클래스 개체이거나, 함수 포인터 타입이거나 함수 포인터로 형변환이 가능한 클래스 개체여야 한다.
그런데 그에 덧붙여 클래스 멤버 문맥에서는 위와 같은 멤버 함수 선언도 들어갈 수도 있으니, C++의 템플릿은 정말 귀에 걸면 귀걸이, 코에 걸면 코걸이가 아닐 수 없다. 심지어 virtual int(PCSTR) 같은 가상 함수 선언도 가능하다!

export 키워드가 괜히 백지화된 게 아님을 느낀다. 템플릿은 워낙 너무 방대한 언어 규격이기 때문에, 템플릿의 몸체를 다른 번역 단위로부터 끌어다 쓸 수 있으려면 템플릿으로 할 수 있는 일의 범위를 좀 더 좁혀야 할 것이다.

그런데 저렇게 멤버 함수를 완전히 customize하는 문법은, 단순히 신기한 것 이상으로 활용 방안이나 유용한 구석이 있는지 잘 모르겠다. 내가 C++ 프로그래밍을 10년이 넘게 해 왔지만, 템플릿으로 저런 것까지 가능하다는 걸 알게 된 건 1년이 채 되지 않았다.
비주얼 C++ 2003도 저게 가능할 정도이니 이건 최신 문법은 아닌 게 분명해 보인다. 그 반면 xcode에서는 이게 지원되지 않는다.

함수 개체를  함수의 인자로 전달할 때는 전통적인 함수 포인터뿐만이 아니라 C++11에서 추가된 람다 함수 오브젝트를 손쉽게 넘겨 줄 수 있다. 이때 템플릿이 아주 유용한 역할을 한다. 가령, 정렬 함수를 호출할 때 비교 함수를 익명 함수로 간단히 알고리즘을 짜서 전해 주면 되니 얼마나 편리한지 모른다.

그 반면 클래스가 함수 오브젝트를 멤버로 받는 건 아무 의미가 없고 가능하지도 않다. 그 대신 클래스 멤버가 템플릿일 때는 이것이 멤버 변수도 되고 아예 멤버 함수도 될 수 있는 자유도가 제공된다고 이해하면 되겠다.

Posted by 사무엘

2012/12/01 19:21 2012/12/01 19:21
,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/763

1. C/C++이 빌드가 느린 이유

베테랑 프로그래머라면 이미 다 알기도 하겠지만, C/C++ (특히 C++)은 강력한 대신 정말 만년 굼벵이 언어가 될 수밖에 없는 요인만 일부러 골고루 가진 채 만들어졌다 해도 과언이 아닌 것 같다.

뭐가 굼벵이냐 하면 두 말할 나위도 없이 빌드 속도 말이다. C#, 자바, 델파이 같은 다른 언어나 툴과 비교하면 안습 그 자체. 이건 컴퓨터의 속도만 빨라진다고 해서 극복 가능한 차원의 차이가 아니라 구조적으로 심한 부담과 비효율 때문이다. 이 점에 대해서는 본인도 예전에 여러 글을 블로그에다 언급한 적이 있다.

  • 일단 C++은 태생이 바이트코드 같은 가벼운 가상 기계가 아니라 철저하게 기계어 네이티브 코드 생성 지향이다. 다른 가벼운(?) 언어들과는 위상부터가 다르며, 이 상태에서 최적화까지 가면 부담은 더욱 커진다. 게다가 이 언어는 설계 철학이 컴파일 시점 때 최대한 많은 걸 미리 결정하는 걸 지향하고 있다. 가령, 자바에 inline이라든가 함수 호출 규약, 레지스터, C++ 수준의 static한 템플릿 메타프로그래밍, 혹은 링크 타임 코드 생성 같은 개념이 있지는 않다.
  • 또한 이 언어는 근본적으로 문법이 상당히 문맥 의존적이고 복잡하여, 구문 분석이 어렵다. 단적인 예로 함수 선언과 객체 선언 A b(c); 변수 선언과 단순 연산식 B*c; 형변환 연산과 단순 연산식 (c)+A 가 c가 무엇인지 문맥에 따라 왔다갔다 하면서 완전히 달라진다. 거기에다 C++의 경우 템플릿, 오버로딩, namespace ADL까지 가면 난이도는 정말 안드로메다로. 다른 언어는 O(n log n) 시간 복잡도만으로도 되는 구문 분석 작업이 C++은 반드시 O(n^2)을 쓰지 않으면 안 되는 과정이 있다고 한다.
  • 빌드를 위해 전처리, 링크 같은 복잡한 계층이 존재하며, 특히 링크는 병렬화도 되지 않아 속도를 더욱 올릴 수가 없는 작업이다. 한 모듈에서 참조하는 함수의 몸체가 다른 어느 번역 단위에 있을지는 전혀 알 수 없다!
    그런데 요즘 C++ 컴파일러의 트렌드는 1에서 잠시 언급했듯이 링크 타임 때의 코드 생성과 최적화(인라이닝 포함)여서 이런 병목 지점에서 더욱 많은 작업량이 부과되고 있다. 이런??

이런 특징은 유독 C/C++ 언어만 개발툴/IDE에서 프로젝트를 만들면 온갖 잡다한 보조 데이터 파일들이 많이 생성되는 이유와도 일맥상통한다. 소스 코드를 잽싸게 분석하여 인텔리센스 같은 똑똑한 IDE 기능을 제공하기가 여타 언어들보다 훨씬 더 어렵기 때문이다.

2. 인클루드의 문제점

그런데, 네이티브 코드 생성, 복잡한 문법 같은 것 이상으로 C/C++의 빌드 시간을 더욱 뻥튀기시키고 빌드 작업을 고달프게 하는 근본적인 요소는 전처리 중에서도 다름아닌 #include 남발이다. C/C++은 남이 만들어 놓은 함수, 클래스, 구조체 같은 프로그래밍 요소를 쓰려면 해당 헤더 파일을 무조건 인클루드해 줘야 한다.

일단 이건 문법적으로는 인위적인 요소가 없이 깔끔해서 좋다. 인클루드되는 헤더는 역시 C/C++ 문법대로 작성된 일반 텍스트 파일이며, 내가 짜는 프로그램이 참조하는 명칭들의 출처가 여기 어딘가에는 반드시 있다고 보장됨을 내 눈으로 확인할 수 있다. 그러나 DB 형태로 최적화된 바이너리 파일이 아니라 파싱이 필요한 텍스트 파일이란 점은 일단 빌드 속도의 저하로 이어진다. 이게 문제점 하나.

본격적인 C++ 프로그램을 하나 만들려면 표준 C/C++ 라이브러리뿐만이 아니라 윈도우 API, MFC, 그리고 다른 3rd-party 라이브러리, 게임 엔진 등 갖가지 라이브러리나 프레임워크가 제공하는 헤더 파일을 참조하게 된다. 이것들을 합하면 한 소스 코드를 컴파일하기 위해 인클루드되는 헤더 파일은 가히 수십, 수백만 줄에 달하게 된다.

게다가 이 인클루드질은 전체 빌드를 통틀어 한 번만 하고 끝인 게 아니라, 이론적으로는 매 번역 단위마다 일일이 새로 해 줘야 한다. 헤더 파일 의존도가 개판이 돼 버리는 바람에 헤더 파일 하나 고칠 때마다 수백 개의 cpp 파일이 재컴파일되는 문제는 차라리 애교 수준이다. 문제점 둘.

보통 헤더 파일에는 중복 인클루드 방지를 위한 guard가 있다.

#ifndef ___HEADER_DEFINED_
#define ___HEADER_DEFINED_

/////

#endif

그런데 #if문 조건을 만족하지 못하는 줄들은 단순히 구문 분석과 파싱만 skip될 뿐이지, 컴파일러는 여전히 중복 인클루드된 헤더 파일도 각 줄을 일일이 읽어서 #else나 #endif가 나올 때까지 들여다보긴 해야 한다.

많은 사람들이 간과하는 사실인데(사실 나도 그랬고), 그때는 컴파일 작업만 잠시 중단됐을 뿐, 전처리기는 전체 소스를 대상으로 여전히 동작하고 있다. 중복 인클루드가 컴파일러의 파일 액세스 트래픽을 얼마나 증가시킬지가 상상이 되는가? guard만 있다고 장땡이 아니며, 이게 근본적으로 얼마나 비효율적인 구조인지를 먼저 알아야 한다. 문제점 셋.

그리고 이 #include의 수행 결과를 컴파일러나 IDE로 하여금 예측이나 최적화를 도무지 할 수가 없게 만드는 치명적인 문제는 극단적인 문맥 의존성이다.
헤더 파일은 그저 static한 함수, 클래스, 변수 선언의 집합체가 아니다. 엄연히 C/C++ 소스 코드의 일부를 구성하며, 동일한 헤더라 해도 어떤 #define 심벌이 정의된 상태에서 인클루드하느냐에 따라서 그 여파가 완전히 달라질  수 있다.

극단적인 예로, 한 소스 파일에서 #define 값만 달리하면서 동일 헤더 파일을 여러 번 인클루드함으로써, 템플릿 비스무리한 걸 만들 수도 있단 말이다. 일례로, 비주얼 C++ 2010의 CRT 소스에서 strcpy_s.c와 wcscpy_s.c를 살펴보기 바란다. 베이스 타입만 #define을 통해 char이나 wchar_t로 달리하여 똑같이 tcscpy_s.inl을 인클루드하는 걸로 구현돼 있음을 알 수 있다...;;

물론 인클루드를 실제로 그렇게 변태적으로 활용하는 예는 극소수이겠지만 인클루드는 여타 언어에 있는 비슷한 기능인 import나 use 따위와는 차원이 다른 개념이며, 캐싱을 못 하고 그 문맥에서 매번 일일이 파싱해서 확인해 보는 수밖에 없다. 문제점 넷이다.

이런 문제 때문에 여타 언어들은 텍스트 파싱을 수반하는 인클루드 대신, 별도의 패키지 import 방식을 쓰고 있으며, Objective C도 #include 대신 #import를 제공하고 헤더 파일은 무조건 중복 인클루드가 되지 않는 구조를 채택하여 셋째와 넷째 문제를 피해 갔다.

비주얼 C++도 #pragma once라 하여 #endif를 찾을 것도 없이 중복 인클루드를 방지하고 파일 읽기를 거기서 딱 중단하는 지시자를 추가했다. 이건 비표준 지시자이긴 하지만 전통적인 #ifdef~#endif guard보다 빌드 속도를 향상시키는 테크닉이기 때문에 프로그래머의 입장에서는 사용이 선택이 아닌 필수이다. 물론, 단순히 중복 선언 에러만 방지하는 게 아니라 특정 헤더 파일의 인클루드 여부를 알려면 재래식 #define도 좀 해 줘야겠지만 말이다.

외부에서 기선언된(predefined) 프로그래밍 요소를 끌어오는데, namespace나 package 같은 언어 계층을 거친 명칭이 아니라 생(raw-_-) 파일명의 지정이 필요한 것부터가 오늘날의 관점에서는 꽤 원시적인 작업이다. 개인적으로는 인클루드 파일의 경로를 찾는 메커니즘도 C/C++은 너무 복잡하다고 생각한다.

""로 싸느냐 <>로 싸느냐부터 시작해서, 인클루드가 또 다른 파일을 중첩으로 인클루드할 때, 다른 인클루드 파일을 자기 디렉터리를 기준으로 찾을지 자신을 인클루드 한 부모 파일의 위치로부터 찾을지, 프로젝트 설정에 명시된 경로에서 찾을지 같은 것 말이다…;; 게다가 인클루드 명칭도 #define에 의한 치환까지 가능하다. #include MY_HEADER처럼. 그게 가능하다는 걸 FreeType 라이브러리의 소스를 보면서 처음으로 알았다.

그런데 그러다가 서로 다른 디렉터리에 있는 동명이인 인클루드 파일이 잘못 인클루드되기라도 했다면.. 더 이상의 자세한 설명은 생략. 내가 무심코 선언한 명칭이 어디엔가 #define 매크로로도 지정되어 있어서 엉뚱하게 자꾸 치환되고 컴파일 에러가 나는 것과 같은 악몽이 발생하게 된다! 문제점 다섯.

이것도 어찌 보면 굉장히 문맥 의존적인 절차이기 때문에, 오죽했으면 비주얼 C++ 2010부터는 인클루드/라이브러리 디렉터리 지정을 global 단위로 하는 게 완전히 없어지고 전적으로 프로젝트 단위로만 하게 바뀌었다는 걸 생각해 보자.

C++ 프로젝트에서 MFC의 클래스나 윈도우 API의 함수를 찍고 '선언으로 가기'를 선택하면 afxwin.h라든가 winbase.h 같은 표준 인클루드 파일에 있는 실제 선언 지점이 나온다. 그 방대한 헤더 파일을 매 빌드 때마다 일일이 파싱할 수가 없으니 인텔리센스 DB 파일 같은 건 정말 크고 아름다워진다.

그에 반해 C# 닷넷 프로젝트에서 Form 같은 클래스의 선언을 보면, 컴파일러가 바이너리 수준에서 내장하고 있는 클래스의 껍데기 정보가 소스 코드의 형태로 생성되어 임시 파일로 뜬다…;; 이게 구시대 언어와 신세대 언어의 시스템 인프라의 차이가 아닌가 하는 생각이 들었다.

그래서 이런 C++ 인클루드 체계의 비효율 문제는 어제오늘 제기되어 온 게 아니기 때문에, 컴파일러 제조사도 좀 더 근본적인 문제 회피책을 간구하게 됐다. 그래서 나온 것이 그 이름도 유명한 precompiled 헤더이다. stdio.h나 stdlib.h 정도라면 모를까, 매 번역 단위마다 windows.h나 afx.h를 일일이 인클루드해서 파싱한다는 건 삽질도 그런 삽질도 없으니 말이다..

3. precompiled header의 도입

일단 프로젝트 내에서 "인클루드 전용" 헤더 파일과 이에 해당하는 번역 단위를 설정한다. 비주얼 C++에서 디폴트로 주는 명칭이 바로 stdafx.cpp와 stdafx.h이다. 모든 번역 단위들이 공용으로 사용하는 방대한 양의 프레임워크, 라이브러리의 헤더를 몰아서 인클루드시킨다. 컴파일러 옵션으로는 Create precompiled header에 해당하는 /Yc "stdafx.h"이다.

그러면 그 헤더 뭉치들은 stdafx.cpp를 컴파일할 때 딱 한 번만 실제로 인클루드와 파싱을 거치며, 이 파일들의 분석 결과물은 빠르게 접근 가능한 바이너리 DB 형태인 프로젝트명.pch 형태로 생성된다.

그 뒤 나머지 모든 소스 파일들은 첫 줄에 #include "stdafx.h"를 반드시 해 준 뒤, Use precompiled header인 /Yu "stdafx.h" 옵션으로 컴파일한다. 그러면 이제 stdafx.h의 인클루드는 실제 이 파일을 열어서 파싱하는 게 아니라, 미리 만들어진 PCH 파일의 심벌을 참고하는 것으로 대체된다! 앞에서 제기한 인클루드의 문제점 중 첫째와 둘째를 극복하는 셈이다.

pch 파일이 생성되던 시점의 문맥과 이 파일이 실제로 인클루드되는 시점의 문맥은 싱크가 서로 반드시 맞아야 한다. 그렇기 때문에 소스 코드에도 문맥상의 제약이 걸린다. PCH를 사용한다고 지정해 놓고 실제로 stdafx.h를 맨 먼저 인클루드하지 않은 소스 파일은 Unexpected end of file이라는 컴파일 에러가 발생하게 된다. PCH 개념을 모르는 프로그래머는 C++ 문법에 아무 하자가 없는 외부 소스 코드가 왜 컴파일되지 않는지 이해를 못 할 것이다.

당연한 말이지만 stdafx.h가 인클루드하는 헤더 파일의 내용이 수정되었다면 PCH 파일은 다시 만들어져야 하며, 이때는 사실상 프로젝트 전체가 리빌드된다. 그러므로 stdafx.h 안에는 거의 수정되지 않는 사실상 read-only인 헤더 파일만 들어가야 한다.

인클루드 파일만 수십, 수백만 줄에 달하는 중· 대형 C++ 프로젝트에서 PCH가 없다는 건 상상조차 할 수 없는 일이다. 얼마 되지도 않는(많게 잡아도 200KB 이내) 소스 코드들이 high-end급 컴에서 그것도 네트워크도 아닌 로컬 환경에서 빌드 중인데 소스 파일 하나당 컴파일에 1초 이상씩 잡아 처먹는다면, 이건 인클루드 삽질 때문일 가능성이 매우 높다. 이 경우, 당장 PCH를 사용하도록 프로젝트 설정을 바꾸고 소스 코드를 리팩터링할 것을 심각하게 고려해야 한다. 이건 작업 생산성과 직결된 문제이기 때문이다.

아놔, 이렇게 글을 길게 쓸 생각은 없었는데 너무 길어졌다.
요컨대 C++ 프로그래머라면 자기의 생업 수단인 언어가 이런 구조적인 비효율을 갖고 있다는 걸 인지하고, 상업용 컴파일러 및 개발툴이 이를 극복하기 위해 어떤 대안을 내놓았는지에 대해 관심을 가질 필요가 있다.

자바, C#, D 등 C++의 후대 언어들은 C++과 문법은 비슷할지언정 이 인클루드 체계만은 어떤 형태로든 제각각 다 손을 보고 개량했음을 알 수 있다. 아까도 언급했듯, 하다못해 Objective C도 중복 인클루드 하나만이라도 자기 식으로 정책을 바꿨지 않던가.

한 가지 생각할 점은, C/C++은 태생이 이식성에 목숨을 걸었고, 언어의 구현을 위해 바이너리 레벨에서 뭔가 이래라 저래라 명시하는 것을 극도로 꺼리는 언어라는 점이다. 그래서 대표적으로 C++ 함수 decoration이 알고리즘이 중구난방인 아주 대표적인 영역이며, 함수 calling convension도 여러 규격이 난립해 있고 모듈/패키지 같은 건 존재하지도 않는다. 그런 차원에서, 비록 비효율적이지만 제일 뒤끝 없는 텍스트 #include가 여전히 선호되어 온 건지도 모르겠다.

4. 여타 언어의 인클루드

여담이다만, 본인은 베이직부터 쓰다가 C/C++로 갈아탄 케이스이기 때문에 인클루드라는 걸 처음으로 접한 건 C/C++이 아니라 퀵베이직을 통해서였다.

'$INCLUDE: 'QB.BI'

바로, 도스 API를 호출하는 인터럽트 함수와 관련 구조체가 그 이름도 유명한 저 헤더 파일에 있었기 때문이다.

C/C++에 전처리기가 있는 반면, 베이직이나 파스칼 계열 언어는 개념적으로 그런 전처리기와 비슷한 위상인 조건부 컴파일이나 컴파일 지시자는 주석 안에 메타커맨드의 형태로 들어있곤 했다. 그러나 여타 프로그래밍 요소를 끌어다 오는 명령은 메타커맨드나 전처리기가 아니라, 엄연히 언어 예약어로 제공하는 게 디자인상 더 바람직할 것이다.

그리고 파워베이직은 퀵베이직 스타일의 인클루드 메타커맨드도 있고, 파스칼 스타일의 패키지 지정 명령도 둘 다 갖추고 있었다.

Posted by 사무엘

2012/09/21 19:25 2012/09/21 19:25
,
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/735

C/C++, 자바, C# 비교

전산학의 초창기이던 1950년대 후반엔 프로그래밍 언어의 조상이라 할 수 있는 코볼, 포트란 같은 언어가 고안되었다. 그리고 이때 범용적인 계산 로직의 기술에 비중을 둔 알골(1958)이라는 프로그래밍 언어가 유럽에서 만들어졌는데, 이걸 토대로 훗날 파스칼, C, Ada 등 다양한 언어들이 파생되어 나왔다.

이때가 얼마나 옛날이냐 하면, 셸 정렬(1959), 퀵 정렬(1960) 알고리즘이 학술지를 통해 갓 소개되던 시절이다. 구현체는 당연히 어셈블리어.;; 그리고 알골이 도입한 재귀호출이라는 게 함수형 언어가 아닌 절차형 언어에서는 상당히 참신한 개념으로 간주되고 있었다. 전산학의 역사를 아는 사람이라면, 컴퓨터를 돌리기 위해 프로그래밍 언어가 따로 만들어진 게 아니라, 프로그래밍 언어를 구현하기 위해 컴퓨터가 발명되었다는 걸 알 것이다.

알골 자체는 시대에 비해 언어 스펙이 너무 복잡하고 막연하기까지 하며, 구현체를 만들기가 어려워서 IT 업계에서 실용적으로 쓰이지 못했다. 그러나 후대의 프로그래밍 언어들은 알골의 영향을 상당히 많이 받았으니 알골은 가히 프로그래밍 언어계의 라틴어 같은 존재로 등극했다.

물론, 그로부터 더 시간이 흐른 오늘날은 알골의 후예에 속하는 언어인 C만 해도 이미 라틴어 같은 전설적인 경지이다. 중괄호 블록이라든가 C 스타일의 연산자 표기 같은 관행은 굳이 C++, 자바, C# 급의 언어 말고도, 자바스크립트나 PHP처럼 타입이 엄격하지 않고 로컬이 아닌 웹 지향 언어에도 그런 관행이 존재하니 말이다.

C가 먼저 나온 뒤에 거기에 OOP 속성이 가미되어 C++이라는 명작/괴작 언어가 탄생했다. C가 구조화 프로그래밍을 지원하는 고급 언어에다가 어셈블리어 같은 저급 요소를 잘 절충했다면, C++은 순수 OOP 개념의 구현보다는 역시 OOP 이념을 C 특유의 성능 지향 특성에다가 적당히 절충을 잘 했다. 그래서 C++이 크게 성공할 수 있었다.

잘 알다시피 C/C++은 모듈이나 빌드 구조가 컴파일 지향적이며, 거기에다 링크라는 추가적인 작업을 거쳐서 네이티브(기계어) 실행 파일을 만드는 것에 아주 특화되어 있다.

번역 단위(translation unit)라고 불리는 개개의 소스들은 프로그래밍에 필요한 모든 명칭들을 텍스트 형태의 다른 헤더 파일로부터 매번 include하여 참조한 뒤, 컴파일되어 obj 파일로 바뀐다.
한 번역 단위에서 참조하는 외부 함수의 실제 몸체는 어느 번역 단위에 있을지 알 수 없다. 어차피 링크 때 링커가 모든 obj 파일들을 일일이 뒤지면서 말 그대로 연결을 하게 된다.

이 링크를 통해 드디어 실행 파일이나 라이브러리 파일이 최종적으로 만들어진다. 실행 파일은 대상 운영체제가 인식하는 실행 파일 포맷을 따라 만들어지지만 static 라이브러리는 그저 obj들의 모음집일 뿐이기 때문에 lib 파일과 obj 파일은 완전히 같지는 않아도 내부 구조가 크게 차이가 나지 않는다.

이런 일련의 컴파일-링크 계층이 C/C++을 로컬 환경에서의 매우 강력한 고성능 언어로 만들어 주는 면모가 분명 있다. 또한 197, 80년대에는 컴퓨터 자원의 한계 때문에 원천적으로 언어를 그런 식으로 설계해야 하기도 했다.
그러나 오늘날은 대형 프로젝트를 진행할 때 C/C++의 그런 디자인은 심각한 비효율을 초래하기도 한다. 내가 늘 지적하듯이 C보다도 특히 C++은 안습할 정도로 빌드가 너무 느리고 생산성이 떨어진다.

그에 반해 자바는 문법만 살짝 비슷할 뿐 디자인 철학은 C++과는 완전히 다른 언어이다. 잘 알다시피 자바는 의도하는 동작 환경 자체가 native 기계어가 아니라 플랫폼 독립적인 자바 가상 기계이다. 컴퓨터 환경이 발달하고 웹 프로그래밍이 차지하는 비중이 커진 덕분에 이런 발상이 나올 수가 있었던 셈이다.

모든 자바 프로그램은 무조건 1코드, 1클래스이며(단, 클래스 내부에 또 다른 클래스들이 여럿 있을 수는 물론 있음), 심지어 소스 파일 이름과 클래스 이름이 반드시 일치해야 한다. 클래스가 곧 C/C++의 ‘번역 단위’와 강제로 대응한다. 그리고 컴파일된 자바 소스는 곧장 컴파일된 바이트코드로 바뀌며, 이것이 자바 VM이 있는 곳이라면 어디서나 돌아가는 실행 파일(EXE)도 되고 라이브러리(DLL, OBJ)도 된다. 물론, 여러 라이브러리들의 집합체인 JAR이라는 포맷도 따로 있기도 하고 말이다.

클래스 내부에 public static void main 메소드(멤버 함수)만 구현되어 있으면 곧장 실행 가능하다. C++은 C와의 호환을 위해 시작 함수가 클래스 없는 일반 main으로 동일하게 지정돼 있는 반면, 자바는 global scope이 존재하지 않고 모든 명칭이 클래스에 반드시 소속돼 있어야 하기 때문에 그렇다. javac 명령으로 소스 코드(*.java)를 컴파일한 뒤, java 명령으로 컴파일된 바이트코드(*.class)를 실행하면 된다.

다른 모듈을 끌어다 쓸 때도 import로 바이너리 파일을 곧장 지정하면 되니, 텍스트 파싱이 필요한 C++의 #include보다 효율적이다. 번거롭게 *.h와 *.lib (그리고 심지어 *.dll까지)를 일일이 따로 구비할 필요 없다.

요컨대 자바는 C++에 비해 굉장히 많은 자유도와 성능을 제약한 대신, C++보다 훨씬 더 손이 덜 가도 되고 빌드도 훨씬 빨리 되고 프로젝트 세팅도 월등히 더 간편하게 되게 만들어졌다. 함수 호출 규약, 인라이닝 방식, C++ symbol decoration, 링크 에러, CRT의 링크 방식, link-time 코드 생성 최적화 같은 온갖 골치 아프고 복잡한 개념들이 자바에는 전혀 존재하지 않는다.
C++이 벙커에 시즈 탱크에 터렛과 마인 등, 손이 많이 가는 테란이라면, 자바는 프로토스 정도는 되는 것 같다.

자바는 하위 호환성을 고려하지 않은 새로운 언어를 만든 덕분에 디자인상 깔끔한 것도 있지만, 상상도 못 할 편리함을 실현하기 위해 성능도 C++ 사고방식으로는 상상도 못 할 정도로 많이 희생한 것 역시 사실이다. 이는 단순히 메모리 garbage collector가 존재하는 오버헤드 이상이다.

그래서 요즘은 자바 바이트코드를 언어 VM이 그때 그때 실시간으로 네이티브 코드로 재컴파일하여, 자바로도 조금이라도 더 빠른 속도를 내게 하는 JIT(just in time)기술이 개발되어 있다. 비록 이 역시 한계가 있을 수밖에 없겠지만 한편으로는 구조적으로 유리한 점도 있다.

컴파일 때 모든 것이 결정되어 버리는 C++ 기반 EXE/DLL은 사용자의 다양한 실행 환경을 예측할 수 없으니 보수적인 기준으로 빌드되어야 한다. 그러나 자바 프로그램의 경우, VM만 그때 그때 최신으로 업데이트하여 최신 CPU의 명령이나 병렬화 테크닉을 쓰게 하면 그 혜택을 모든 자바 프로그램이 자동으로 보게 된다. 물론 C++로 치면 cout이 C의 printf보다 코드 크기가 작아지는 경지에 다다를 정도로 컴파일러가 똑똑해져야겠지만 말이다.

자바 얘기가 길어졌는데, 다음으로 C#에 대해서 좀 살펴보기로 하자.
C# 역시 네이티브 코드 지향이 아니라 닷넷 프레임워크에서 돌아가는 바이트코드 기반인 점, 복잡한 링크 메커니즘을 생략하고 C++의 지나치게 복잡한 문법과 모듈 구조를 간소화시켰다는 점에서는 자바와 문제 접근 방식이 같다.

단적인 예로, 클래스를 선언하면서 멤버 함수까지 클래스 내부에다 정의를 반드시 집어넣게 한 것, 그리고 생성자 함수의 호출이 수반되는 개체의 생성은 반드시 new를 통해서만 가능하게 한 것은 컴파일러와 링커가 동작하기 상당히 편하게 만든 조치이다. 이는 자바와 C#에 공통적으로 적용된다.

다만 C#은 자바처럼 엄격한 1소스 1클래스 체계는 아니며, 빌드 결과물로 엄연히 일반적인(=윈도우 운영체제가 사용하는 PE 포맷 기반인) EXE와 DLL이 생성된다. 물론 내부엔 기계어 코드가 아닌 바이트코드가 들어있지만 말이다.

C# 역시 클래스 내부에 존재하는 static void Main가 EXE의 진입점(entry point)이 된다. 그러나 C#은 자바 같은 1소스, 1클래스, 1모듈 구조가 아니기 때문에 여러 클래스에 동일한 static void Main이 존재하면 컴파일러가 어느 것을 진입점으로 지정해야 할지 판단할 수 없어서 컴파일 에러를 일으킨다. 링크나 런타임 에러가 아님. 진입점을 별도의 컴파일러 옵션으로 따로 지정해 주거나, Main 함수를 하나만 남겨야 한다.

여담이지만, C#의 진입점 함수는 자바와는 달리 첫 글자 M이 대문자이다. 전통적으로 자바는 첫 글자를 소문자로 써서 setValue 같은 식으로 메소드 이름을 지어 온 반면, 윈도우 세계는 그렇지 않기 때문이다(SetValue).
그리고 C#의 Main은 굳이 public 속성이 아니어도 된다. 어차피 진입점인데 접근 권한이 무엇이면 어떻냐는 식의 발상인 것 같다.

닷넷 실행 파일이 사용하는 바이트코드는 자바와 마찬가지로 기계 독립적인 구조이다. 그러나 그것의 컨테이너라 할 수 있는 윈도우 운영체제의 실행 파일 포맷(PE)은 여전히 CPU의 종류를 명시하는 필드가 존재한다. 그리고 32비트와 64비트에서 필드의 크기가 달라지는 것도 있다. 이것은 기계 독립성을 추구하는 닷넷의 이념과는 어울리지 않는 구조이다. 그렇다면 닷넷은 이런 상황을 어떻게 대처하고 있을까?

내가 테스트를 해 본 바로는 플랫폼을 ‘Any CPU’라고 지정하면, 해당 C# 프로그램은 명목상 그냥 가장 무난하고 만만한 x86 껍데기로 빌드되는 듯하다.
작정하고 x64 플랫폼을 지정하고 빌드하면 헤더에 x64 CPU가 명시된다. 뒤에 이어지는 바이트코드는 어느 CPU에서나 동일하게 생성됨에도 불구하고, 그 프로그램은 x86에서는 실행이 거부되고 돌아가지 않게 된다.

그러니, 64비트 네이티브 DLL의 코드와 연동해서 개발되는 프로그램이기라도 하지 않은 이상, C# 프로그램을 굳이 x64용으로 제한해서 개발할 필요는 없을 것이다. 다만, x86용 닷넷 바이너리는 관례적으로 닷넷 런타임인 mscoree.dll에 대한 의존도가 추가되는 반면 x64용 닷넷 바이너리는 그런 게 붙어 있지 않다. 내 짧은 생각으론, 64비트 바이너리는 32비트에서 호환성 차원에서 넣어 줘야 했던 잉여 사항을 생략한 게 아닌가 싶다.

DLL에 기계 종류와 무관한 리소스나 데이터가 들어가는 일은 옛날부터 있어 왔지만, 닷넷은 코드조차도 기계 종류와 무관한 독립된 녀석이 들어가는 걸 가능하게 했으니 이건 참 큰 변화가 아닐 수 없다. 네이티브 쪽과는 달리 골치 아프게 32비트와 64비트를 일일이 신경 쓸 필요가 없고, 한 코드만으로 x86(-64) 계열과 ARM까지 다 커버가 가능하다면, 정말 어지간히 하드코어한 분야가 아니라면, 월등한 생산성까지 갖추고 있는 C#/자바 같은 개발 환경이 뜨지 않을 수 없을 것 같다. C++과 자바, C#을 차례로 비교해 보니 그런 생각이 들었다.

Posted by 사무엘

2012/06/16 19:37 2012/06/16 19:37
, , ,
Response
No Trackback , 7 Comments
RSS :
http://moogi.new21.org/tc/rss/response/696

C++11의 람다 함수

프로그래밍을 하다 보면, 어떤 컨테이너 자료구조의 내부에 있는 모든 원소들을 순회하면서 각 원소에 대해 뭔가 동일한 처리를 하고 싶은 때가 빈번히 발생한다.
그 절차를 추상화하기 위해 C++ 라이브러리에는 algorithm이라는 헤더에 for_each라는 템플릿 함수가 있다. 다음은 이 함수의 로직을 나타낸 C++ 코드이다. 딱히 로직이라 할 것도 없이 아주 직관적이고 간단하다.

template<typename T, typename F>
void For_Each_Counterfeit(T a, T b, F& c)
{
    for(T i=a; i!=b; ++i) c(*i);
}

C++은 템플릿과 연산자 오버로딩을 통해 자료구조에 대한 상당 수준의 추상화를 달성했다.
iterator에 해당하는 a와 b는 그렇다 치더라도, 여기서 핵심은 c이다.
F가 무엇인지는 모르겠지만, 어쨌든 c는 함수 호출 연산자 ()를 적용할 수가 있는 대상이어야 한다.
그럼 무엇이 가능할까? 여러 후보들이 있다.

일단 C언어라면 함수 포인터가 떠오를 것이다. 함수 포인터는 코드를 추상화하는 데 지금까지 고전적으로 쓰여 온 기법이다.

void foo(char p);

char t[]="Hello, world!";
For_Each_Counterfeit(t, t+strlen(t), foo);

C++에서는 클래스가 존재하는 덕분에 더 다양한 카드가 생겼다. 클래스가 자체적으로 함수 호출을 흉내 내는 연산자를 갖출 수 있기 때문이다.

class MyObject {
public:
    void operator()(char x);
};

For_Each_Counterfeit(t, t+strlen(t), MyObject());

그리고 더욱 기괴한 경우이지만, 클래스 자신이 함수의 포인터로 형변환이 가능해도 된다.

class MyObject {
public:
    typedef void (*FUNC)(char);
    operator FUNC();
};

For_Each_Counterfeit(t, t+strlen(t), MyObject());

C++ 라이브러리에는 functor 등 다양한 개념들이 존재하지만, 그 밑바닥은 결국은 C++ 언어의 이런 특성들을 사용해서 구현되어 있다.
여기서 재미있는 점이 있다. 다른 자료형과는 달리 함수 포인터로 형변환하는 연산자 오버로드 함수는, 자신이 가리키는 함수의 prototype을 typedef로 미리 만들어 놓고 반드시 typedef된 명칭으로 선언되어야 한다는 제약이 있다. 이것은 C++ 표준에도 공식적으로 명시되어 있는 제약이라 한다.

이런 어정쩡한 제약이 존재하는 이유는 아마도 함수 선언문에다가 다른 함수를 선언하는 문법까지 덧붙이려다 보니, 토큰의 나열이 너무 지저분해지고 컴파일러를 만들기도 힘들어서인 것 같다. 이 부분에서는 아마 C++ 위원회에서도 꽤 고민을 하지 않았을까.
안 그랬으면 형변환 연산자 함수의 prototype은 아래와 비슷한 괴상한 모양이 됐을 것이다. 실제로 이 함수의 full name을 undecorate한 결과는 이것처럼 나온다.

    operator void (*)(char)();

비주얼 C++에서는 함수를 저렇게 선언하면 그냥 * 부분에서 '문법 에러'라는 불친절한 말만 반복할 뿐이지만, xcode에 기본 내장되어 있는 최신형 llvm 컴파일러는 놀랍게도 나의 의도를 간파하더이다. “함수 포인터로 형변환하려면 반드시 typedef를 써야 합니다”라는 권고를 딱 하는 걸 보고 적지 않게 놀랐다. 이런 차이도 맥북을 안 쓰고 오로지 비주얼 C++ 안에서만 틀어박혀 지냈다면 경험하기 쉽지 않았을 것이다. 우왕~

() 연산자 오버로딩은 this 포인터가 존재하는 C++ 클래스 멤버 함수이며 static 형태가 있을 수 없다.
그러나 함수 포인터로의 형변환 연산자 오버로딩은 this가 없으며 C 스타일의 static 함수와 같은 위상이라는 차이가 존재한다.
두 오버로딩이 모두 존재하면 어떻게 될까? 혹시 모호성 오류라도 나는 걸까?

그런 개체에 함수 호출 ()가 적용되는 경우, () 연산자가 먼저 선택되며, 그게 없을 때에 한해서 함수 포인터 형변환이 차선책으로 선택된다. 모호성 오류가 나지는 않는다.
포인터 형변환 연산자와 [] 연산자가 같이 있을 때 개체에 배열 첨자 참조 []가 적용되는 경우, 역시 [] 가 먼저 선택되고 그게 없을 때 포인터 형변환이 차선으로 선택되는 것과 비슷한 맥락이라 볼 수 있다.

그래서 클래스와 연산자 오버로딩 덕분에 저런 문법이 가능해졌는데, C++11에서는 그걸로도 모자라 또 새로운 문법이 추가되었다. 이른바 람다 함수.

For_Each_Counterfeit(t, t+strlen(t), [](char x) { /* TODO: add your code here */ } );

람다 함수는 코드가 들어가야 할 곳에 함수나 클래스의 작명 따위를 신경쓰지 않고 코드 자체만을 직관적으로 곧장 집어넣기 위해 고안되었다. 세상에 C++에서 OCaml 같은 데서나 볼 수 있을 법한 개념이 들어가는 날이 오다니, 신기하지 않은가?

덕분에 C++은 C언어 같은 저수준 하드웨어 지향성에다가 성능과 이념을 적당히 절충한 수준의 객체지향을 가미했고, 90년대 중반에는 템플릿 메타프로그래밍 개념을 집어넣더니, 이제는 함수형 언어의 개념까지 맛보기로 도입한 가히 멀티 패러다임 짬뽕 언어가 되었다.

함수를 값처럼 표현하기 위해서 lambda 같은 예약어가 별도로 추가된 게 아니다. C/C++은 태생상 예약어를 함부로 추가하는 걸 별로 안 좋아하는 언어이다. (그 대신 문법에 혼동이 생기지 않는 한도 내에서 기호 짬뽕을 좋아하며 그래서 사람이나 컴파일러나 코드를 파싱하는 난이도도 덩달아 상승-_-) 보아하니 타입을 선언하는 부분에서는 배열 첨자가 먼저 오는 일이 결코 없기 때문에 []를 람다 함수 선언부로 사용했다.

람다 함수는 다른 변수에 대입되어서 두고두고 재활용이 가능하다. 그래서 C/C++에서는 전통적으로 가능하지 않은 걸로 여겨지는 함수 내부에서의 함수 중첩 선언을 이걸로 대체할 수 있다.

어떤 함수 안에서 특정 코드가 반복적으로 쓰이긴 하지만 별도의 함수로 떼어내기는 싫을 때가 있다. 굳이 함수 호출 오버헤드 때문이 아니더라도, 해당 코드가 그 함수 내부의 지역변수를 많이 쓰기 때문에 그걸 일일이 함수의 매개변수로 떼어내기가 귀찮아서 그런 것일 수도 있다.
이때 흔히 사용되는 방법은 그냥 #define 매크로 함수밖에 없었는데 이때도 람다 함수가 더 깔끔하고 좋은 해결책이 될 수 있다. 람다 함수는 선언할 때 캡처라 하여 주변의 다른 변수들을 참조하는 메커니즘도 언어 차원에서 제공하기 때문이다.

그렇다면 의문이 생긴다.
람다 함수는 그럼 완전히 새로운 type인가?
기존 C/C++에 존재하는 함수 포인터와는 어떤 관계일까?
정답부터 말하자면 이렇다. 람다 함수는 비록 어쩌다 보니 () 연산을 받아 주고 함수 포인터가 하는 일과 비슷한 일을 하게 됐지만, 활용 형태는 함수 포인터하고 아무 관련이 없으며 그보다 더 상위 계층의 개념이다.

템플릿과 연동해서 쓰인다는 점에서 알 수 있듯, 람다 함수는 함수 포인터와는 달리 calling convension (_stdcall, _cdecl, _pascal 나부랭이 기억하시는가?)이고 리턴값이 나발이고간에 아무 상관이 없다. 그저 코드상에서 함수를 값처럼 다루는 걸 돕기 위해 존재하는 추상적인 개념일 뿐이다. 뭔가 새로운 type이 아니기 때문에 람다 함수를 변수에다 지정할 때는 auto만을 쓸 수 있다. 즉, 다른 자료형이 아닌 람다에 대해서는 auto가 선택이 아니라 필수라는 뜻이다.

auto square=[](int x) { return x*x; };
int n = square(9); //81

square는 템플릿 같은 함수가 아니다. 이 함수의 리턴값은 x*x로부터 자동으로 int라고 유추되었을 뿐이다. [](int x) -> int 라고 명시적으로 리턴 타입을 지정해 줄 수도 있다. 구조체 포인터의 멤버 참조 연산자이던 -> 가 여기서 또 화려하게 변신을 한 셈임. 우와!

또한, sizeof(square)을 한다고 해서 포인터의 크기가 나오는 게 아니다. 사실, 람다 함수에다가 sizeof를 하는 건 void에다가 sizeof를 하는 것만큼이나 에러가 나와야 정상이 아닌가 싶다. 그런 개념하고는 아무 관계가 없기 때문이다.

람다는 함수 포인터가 아니기 때문에, square에다가 자신과 프로토타입이 같은 다른 람다 함수를 대입할 수 있는 건 아니다. 함수 포인터의 역할과 개념을 대체할 뿐, 그 직접적인 디테일한 기능을 대체하지는 못한다. 그렇기 때문에 콜백 함수를 받는 문맥에서

qsort(n, arrsize, sizeof(int), [](const void *a, const void *b) { return *((int*)a) - *((int*)b); } );

구닥다리 C 함수에다가 최신 C++11 문법이라니, 내가 생각해도 정말 변태 같은 극단적인 예이다만,
이런 식으로 람다 함수를 집어넣을 수도 없다.

요컨대 람다 함수는 코드의 추상화에 도움을 주고 종전의 함수 포인터 내지 #define, 콜백 함수 등의 역할을 대체할 수 있는 획기적인 개념이다. C++ 철학대로 디자인된 여타 C++ 라이브러리와 함께 사용하면 굉장한 활용 가능성이 있다. 그러나 이것은 함수 포인터에 대한 syntatic sugar는 절대 아니라는 걸 유의하면 되겠다.

Posted by 사무엘

2012/05/24 08:38 2012/05/24 08:38
, , ,
Response
No Trackback , 15 Comments
RSS :
http://moogi.new21.org/tc/rss/response/686

« Previous : 1 : 2 : 3 : 4 : 5 : 6 : 7 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/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:
1292482
Today:
69
Yesterday:
464