C++에서 A라는 클래스를 만들었다. 이 클래스는 앞으로 당신이 만들 거의 모든 클래스들이 상속 받을 아주 기본적이고 공통적인 기능을 갖추고 있다. COM으로 치면 IUnknown, MFC로 치면 CObject 같은 기능을 하는데, 여기서는 그 예로 자체적인 reference counting 기능을 내장하고 있다고 치자.

class A {
 int nRefCnt;
public:
 A(): nRefCnt(1) {}
 ~A() {}
 int AddRef() { return ++nRefCnt; }
 int Release() { int nt=--nRefCnt; if(nt==0) delete this; return nt; }
};

이제 당신은 A로부터 상속 받은 여러 클래스들을 만든다.

class B: public A {
public:
 int nAddVal;
 B(): nAddVal(2) {}
};

class C: public A {
public:
 int nExitVal;
 C(): nExitVal(3) {}
};

그런데 C++에는 다중 상속이라는 게 존재한다.
어쩌다 보니, A의 자식 클래스들 중 서로 다른 클래스를 골라서 이들의 기능을 다 물려받은 클래스를 만들고 싶어진다. (욕심도 참 많다!)

class D: public B, public  C {
public:
 int nLast;
 D(): nLast(4) {}
};

여기서 문제가 생긴다.
이 경우, D는 B와 C의 기능을 물려받는 과정에서, B와 C가 공동으로 소유하는 A는 두 번 상속받게 된다.
32비트 기준으로 obj의 멤버 배열 순서는 대략 "1, 2, 1, 3, 4" 정도가 된다.

D obj;
obj.AddRef();

이 코드의 실행 결과는 어떻게 될까?
고민할 필요 없다. 이 코드는 컴파일 자체가 되지 않을 테니 말이다. =_=
D라는 클래스에는 A의 nRefCnt라는 멤버 자체가 둘 존재한다.
그렇기 때문에 B 쪽에 속하는 nRefCnt를 건드릴지, C 쪽에 속하는 nRefCnt를 건드릴지 판단할 수 없어서 컴파일러는 모호성 오류를 일으키는 것이다.
클래스 가계도는 보통 tree 구조가 되는데 이 경우 엄밀히 말하면 cycle이 존재하게 된다. 이 cycle을 일명 '죽음의 다이아몬드'라고 부른다.

obj.B::AddRef() 내지 obj.C::AddRef()라고 구문을 바꾸면 컴파일 에러 자체는 없앨 수 있다.
그러나 이것은 미봉책일 뿐이지 문제를 본질적으로 해결하는 방법은 아닐 것이다.
이건 클래스 B와 C가 아무 공통분모 없이, 우연히 AddRef라는 껍데기만 동일하고 의미는 완전히 다른 함수를 제각기 갖고 있는 것과 다를 게 없는 상황이다.
근본적으로는 D라는 클래스는 비록 B와 C의 기능을 동시에 상속 받았더라도 A는 단 한 번만 상속 받게 하는 방법이 있어야 한다. 그게 가능할까?

그래서 C++은 '가상 상속'이라는 걸 제공한다.
일반적으로 B라는 클래스가 A라는 클래스로부터 상속을 받았다면, B라는 클래스는 내부적으로 A의 몸체 뒤에 자기 몸체가 덧붙는다. 따라서 B 클래스의 오프셋과 A 클래스의 오프셋 사이의 간격은 컴파일 시간 때 딱 결정이 되어 버리며 언제나 고정 불변이다.

그런데 B가 A를 상속 받으면서 A를 '가상'으로 상속하면, B 클래스로부터 A 클래스의 오프셋은 자기가 별도의 내부 멤버로 갖고 있게 되며, 컴파일 시점이 아니라 실행 시점 때 동적으로 바뀔 수 있게 된다. 기반 클래스가 특수한 처리를 하는 게 아니라, 상속을 받고 싶어하는 자식 클래스가 상속을 특수한 방법으로 받아야 한다.
그래서 위의 네 클래스 A~D 중, 죽음의 다이아몬드를 해소하기 위해서는 B와 C가 A를 virtual로 상속 받게 하면 된다.

class B: virtual public A { ... };
class C: virtual public A { ... };

이 경우 B와 C는, 기반 클래스인 A가 자신과 메모리 상으로 굳이 연속적으로 이어져 있지 않더라도, B나 C 자신이 스스로 갖고 있는 부가 정보를 통해 기반 클래스인 A의 위치를 추적할 수 있다.

클래스 C만 갖고 생각하는 경우, 당연히 메모리 상으로는 A와 C가 바로 따를 것이고, C 내부에 있는 A의 포인터는 자기 바로 앞을 가리키고 있을 것이다.
하지만 클래스 D는 멤버가 ABCD와 같은 순으로 쫙 배열될 수 있으며, A와 C 사이에 B 같은 다른 클래스가 있을 수 있다. 그래도 B와 C가 A를 공유할 수 있게 된다. 공유를 하려고 이 지저분한 짓을 자처한 것이다.

자, 그럼 가상 기반 클래스의 구현 비용은 어느 정도 될까? C++에서

ptr->Function(a, b);

이라는 문장이 있고 Function이 virtual이 아닌 일반 클래스 멤버 함수라면, 위의 코드는 C 언어 문법으로 표현했을 때 대략

Function(ptr, a, b);

이 된다. 즉, this만 암묵적으로 추가되고 일반 함수와 완전히 똑같은 형태이다. 가장 간단하다.
하지만 Function이 가상 함수라면,

ptr->functbl->Function(ptr, a, b);

과 같은 꼴이 되고 오버헤드가 꽤 커진다. 우리 멤버가 가리키는 공용 가상 함수 테이블로 가서 거기서 함수 포인터를 참조하는 셈이다.
그렇다면 Function이 ptr이 가상으로 상속 받은 기반 클래스의 비가상 멤버 함수라면,

Function(ptr + ptr->baseptr, a, b);

정도. 가상 함수 정도는 아니지만 그래도 this 포인터의 위치 계산을 위해서 두세 개의 명령 오버헤드가 추가된다.
일단 다중 상속이라는 개념 자체가 컴파일러 문법상의 단순 형변환일 뿐이던 typecasting을 굉장히 복잡하게 만들었다는 점을 알 필요가 있다.

그럼 Function이 가상 상속에다가 가상 함수이기까지 하면 어떻게 될까?
그래도 functbl을 찾는데 오버헤드가 더해지지는 않는다. 어차피 각 클래스의 함수 테이블에는 자기가 지금까지 상속 받은 모든 클래스의 가상 함수들이 누적 기록되어 있기 때문에, 함수 호출을 위해 여러 테이블을 돌아다니지는 않는다.

참고로 A에 가상 함수가 있고 B와 C가 이를 제각기 오버라이드를 했는데 D가 B와 C를 동시에 상속 받고도 그 가상 함수를 또 오버라이드하지 않았다면, 컴파일 에러가 난다. B와 C 중 어느 장단에 맞춰 춤을 추리요? C++ 컴파일러는 그 정도 모호성은 자동으로 지적해 준다. C++ 컴파일러 만들기란 정말 힘들겠다는 생각이 들지 않는가?

가상 함수, 가상 상속, 멤버 함수 포인터... =_=;;
오늘날 프로그래밍 업계에서 다중 상속은 굉장히 지저분하고 흉악한 개념으로 간주되어 금기시되고 있다. C언어의 전처리기, 포인터와 더불어 현대의 프로그래밍 언어에서는 찾을 수 없는 면모가 되고 있다.
여러 클래스의 기능이 한꺼번에 필요하면, 무리하게 상속으로 해결하지 말고 해당 클래스 개체를 '멤버'로 가지는 쪽으로 가라는 것이다.

다중 상속이라든가 가상 상속이 골치아픈 게 결국은 데이터 멤버들의 오프셋 계산 때문이다. 하지만 가상 함수만 잔뜩 만드는 것은 그 클래스 자체가 아닌 함수 테이블의 덩치를 대신 키우는 것이고 클래스 자체는 가상 상속 같은 복잡한 테크닉을 필요로 하지 않기 때문에, 대다수 현대 객체 지향 언어들은 '인터페이스'라는 개념을 도입하여 이것으로 다중 상속의 기능을 어느 정도 대체하고 있다.
사실, C++의 각종 어려운 OOP 개념을 실제로 구현하는 데 비용이 얼마나 드는지를 잘 이해하고 있는 것만으로도 상당한 수준의 프로그래밍 내공을 쌓을 수 있다!

Posted by 사무엘

2010/04/14 17:19 2010/04/14 17:19
,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/245

C는 작고 쪼잔하고 오덕스럽게 만들어진 언어이다(이런 특성을 상당수 물려받은 C++도 포함). 문법에서도 이런 면모가 발견되는데, 가능한 한 예약어 개수를 줄이고 연산자와 기호만으로, 그리고 이 토큰이 쓰인 주변 문맥을 통해서 구문의 의미가 파악되도록 언어를 설계한 것이다.

비슷한 계열의 구조화 프로그래밍 언어인 파스칼과 비교하면 문법이 얼마나 극단적으로 다른지 알 수 있다. begin end 대신 간단히 { } 이다. function, procedure처럼 서브루틴을 나타내는 예약어가 따로 존재하지 않는다. 그냥 자료형과 ()가 함수를 나타내며, 아예 void라는 예약어가 따로 존재한다. var 같은 예약어도 없이 변수 선언이 바로 가능하다. forward 같은 예약어가 없어도 함수의 선두 선언이 가능하며, 별도의 array 예약어가 없이 배열을 선언할 수도 있다. 순수 가상 함수를 선언하는데  pure 같은 별도의 예약어를 추가한 게 아니라 그냥 함수 = 0이란 표현으로 대체한다. 이게 바로 C++의 사고방식이다.

이렇게 극도로 함축적인 문법 덕분에, 프로그래머는 일단 타이핑을 덜 해도 되니 좋다. 1970년대에는 언어도 기계 저수준 프로그래밍을 위해 한없이 쪼잔해져야만 했던 때임을 기억할 필요가 있다. 그 시절에 무슨 인텔리센스라든가 코드 자동 완성 같은 사치스러운 기능이 있었단 말인가?

하지만 이런 언어 구조 때문에 C, 특히 C++은 코드를 알아보고 구문 분석하기가 무척 까다로운 언어가 되고 말았다. 사람에게만 힘든 게 아니라 컴파일러 입장에서도 말이다. 단순히 암호 같은 포인터 참조와 연산자 남발 때문에 알아보기 어려운 차원이 결코 아니다.

전산학적으로 말하면 C/C++의 문법은 문맥 자유 문법이 아니다. 가령 C++ 언어의 global scope에서,

a b(c, d);
위의 문장은 C++의 경우 함수의 선언일까, 아니면 개체의 선언일까?

a<100> b;
그리고 위의 문장은 템플릿을 이용한 개체일까, 아니면 비교 연산일까?

즉, a~d의 타입이 무엇이냐에 따라 구문의 의미, 즉 파싱 방법이 완전 극단적으로 달라진다. 마치 보는 방식에 따라 GOOD으로도 보이고 EVIL로도 읽히는 중의적인 그림처럼 말이다.
C++의 문법은, 의미를 파악하기 위해 파싱을 하는데 각 토큰의 의미를 모르면 제대로 파싱을 할 수 없는 그런 구조인 것이다!

그렇기 때문에 C++ 코드는 IDE 차원에서 간단한 인텔리센스나 자동 완성 기능만 구현하기 위해서라도 코드를 전부 읽어서 사실상 컴파일을 해 봐야 한다. 게다가 전처리기를 거쳐서 #define 심볼까지 일일이 벗기면서 말이다. C#이나 자바는 C++과 매우 유사한 구문을 갖고 있고 똑같이 { } 블록 구조이지만, 문맥 자유 문법을 갖추고 있으며, 의미 분석이 C++보다 훨씬 더 간단하다.

파스칼은? 더 말이 필요 없다. 소스 코드를 단 한 번만 읽으면서 앞으로 되돌아갈 필요조차 없이 구문 분석 + 코드 생성이 다 되는 구조이다! 물론 같은 의미를 표현하더라도 C/C++보다 거추장스럽고 프로그래머가 불편한 게 더 많긴 하지만 말이다.

자바와 C#이 C++에 존재하는 모호성을 없앤 것 중 하나는 new 연산자이다.
생성자 함수 호출을 동반하는 개체는 무조건 new로 선언하게 되어 있기 때문에, new가 동반되지 않은 a b(c, d) 같은 구문은 일단 개체 선언은 절대 아니고 함수 선언이라고 보장할 수 있는 것이다.

C/C++의 문법을 더욱 문맥 의존적이고 지저분한 판타지로 바꾼 것 중 하나는 type casting이다. 별도의 type casting 연산자나 예약어가 존재하는 게 아니라 그냥 앞에다가 타입 이름을 써서 괄호로 싸는 걸로 형변환이 되게 만들어 버렸으니 원...  (a)+b 라는 구문에서 +는 a가 무엇이냐에 따라서 이항 연산자일 수도 있고 아닐 수도 있다. 포인터의 의미를 겸하고 있는 * 까지 가면 더욱 복잡해진다.

게다가 C++에서는 생성자 변환 스타일까지 허용되니 더욱 지저분해졌다! (type)value 뿐만 아니라 type(value)까지 된다는 소리. 이런 어정쩡한 문법 때문에, 소스 코드에서 명시적인 형변환이 일어나는 곳만 딱 찾기도 곤란하다는 점 역시 큰 문제였다.

보다못해 1990년대 중반에는 C++에 4종류의 별도의 형변환 연산자가 예약어로 추가됐다. static_, dynamic_, reinterpret_, const_로 시작하는 cast 연산자가 그것이다. 취지는 좋은데 C언어 철학과는 전혀 어울리지 않을 정도로 예약어 길이가 너무 긴 게 흠이다.

C++은 C언어의 호환성을 존중하여 설계되었지만, 그렇다고 해서 C의 strict superset으로 설계된 것도 아니다. 일부 문법은 바뀌거나 더 엄격해졌기 때문에, 어떤 C 코드는 C++ 언어 문법으로는 컴파일이 되지 않는다. C 영역과 C++ 영역을 엄밀하게 분리한 것도 아니고 그냥 어중간하게 C에다가 OOP 개념을 집어넣다 보니 문법은 더욱 복잡해지고, 특히 동일한 개념을 나타내는 문법이 여럿 존재하는 등( (int)a, int(a)라든가, 포인터와 참조자 중복처럼 ㅋㅋ), 참을 수 없는 지저분함에 환멸을 느끼는 프로그머도 존재할 정도이다.

그래도 오늘날까지 컴퓨터와 직통으로 네이티브 대화가 가능한 가장 강력하고 효율적인 언어라는 장점, 오로지 그거 하나 때문에 C/C++은 메이저급 언어로 군림 중이다. 더 깔끔하고 수학적으로 엄밀한 프로그래밍 언어를 좋아하는 사람이라면 이런 현실을 결코 달갑게 여기지 않겠지만 말이다.

Posted by 사무엘

2010/04/10 19:20 2010/04/10 19:20
,
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/241

printf/scanf가 받는 % 문자는 이식성 면에서 매우 큰 문제를 일으킬 수 있다. 기계 종류와 운영체제/컴파일러(정확하게는 CRT 라이브러리)의 종류에 따라 미묘한 차이가 존재하기 때문이다.

이런 잡음이 제일 없던 꿈 같은 시절은 단연 32비트 시절이다. 포인터와 정수가 전부 4바이트가 됨으로써 %d와 %ld 같은 골치아픈 구분도 없어졌고, 포인터도 far/huge 같은 구분이 없어져서 모든 것이 32비트 단위로 끝이 났기 때문이다. %d, %x, %u 하나만으로 컴퓨터에서 통용되는 거의 모든 정수를 바로 읽고 쓸 수 있던 시절. -_-

* * * * * *
Note 1
  참고로 정수가 아닌 실수는?
16비트 시절에는 터보 C/C++에 무려 10바이트 크기의 실수인 long double이 있었고, 파스칼에는 아예 6바이트짜리 Real이라는 기괴한 실수가 존재했다. CPU의 machine word가 16비트 크기이고, GPU는커녕 부동소숫점 전용 프로세서(FPU)마저 흔치 않아서 이런 연산도 소프트웨어적으로 직접 구현하는 게 당연시되던 시절이었으니까 그런 게 존재 가능했다.
요즘 세상엔 무조건 32비트 float 아니면 64비트 double이지, 저런 건 상상도 못 할 개념일 것이다. 픽셀 크기조차도 옛날에는 트루컬러 24비트이다가 요즘은 컴퓨터가 더 처리하기 편한 형태인 32비트이다.
* * * * * *

하지만 이렇게 32비트 천하통일 시대에 끝이 보이기 시작한 것은, 64비트 컴퓨터가 속속 등장하고 문자열도 일반적인 8비트 크기가 아닌 16비트 단위의 소위 wide string이 공존하게 되고부터이다.

그럼, 이번에도 역시 숫자부터 예를 들어 보겠다.

32비트 윈도우 + 비주얼 C++의 CRT는
32비트 정수를 주고받을 때는 당연히 그대로 %d나 %u를 주면 되고 별도의 크기 지정자가 필요 없다. 하지만 64비트 정수에 대해서는 I64라는 접두사를 넣어서 %I64d처럼 해야 한다.

이 규칙은 64비트에서도 완전히 동일하게 적용되기 때문에 이식이 쉽다.
특히 호환성을 극도로 중요시하는 윈도우는 64비트 기계에서도 int 형을 32비트 4바이트로 책정한 관계로, 64비트에서도 %d가 아닌 %I64d를 해 줘야 32비트 영역을 넘어서는 정수를 읽거나 쓸 수 있다. 64비트 기계이더라도 숫자는 일단 변함없이 32비트가 주류라는 인상을 넣은 것이다.

* * * * * *
Note 2
  윈도우즈 문화권은 왜 이리도 호환성에 목숨을 걸까?
간단하다. 그쪽은 오픈소스 진영과는 근본적으로 분위기가 다르기 때문이다.
모든 것이 투명하게 소스 공개이고, 사용자들이 다 컴퓨터를 능수능란하게 다루는 능동적인 해커들인 세상에서는 뭔가 소프트웨어를 업데이트해도 줘도 못 먹는 사람이 없이 물갈이도 금방 된다. 소프트웨어 계층에 breaking change가 잦더라도 재컴파일 한 번으로 '끗'이며, 그렇게 문제가 되지 않는다.

하지만 여기는 사정이 다르다. 마우스로 느릿느릿 아이콘 클릭밖에 못 하고 악성 코드에 속수무책으로 당하는 컴맹도 많다. 또한 돈 내기 싫어서 구닥다리 OS를 계속 고집하는 사람도 많다. 오로지 MS라는 회사가 모든 내부 사정을 관장하고 고객을 다 떠먹여 줘야 한다. 그러니 무조건 한번 만들어 놓은 것에 대한 유지 관리가 편리한 시스템을 만들어야 하며, 새 제품을 단절 없이 많이 팔려면 하위 호환성이라는 보수적인 가치를 최우선 순위로 두고 목숨 걸 수밖에 없는 것이다.
* * * * * *

단, 딱 하나 문제가 될 수 있는 것은 소위 INT_PTR 타입으로, 32비트 기계에서는 32비트이지만, 64비트에서 실제로 64비트 크기로 확장되는 정수이다. 이게 진짜로 포인터의 크기와 같으며 machine word와 크기가 일치함이 보장되는 정수이다.

이런 정수를 다루는 프로그램의 이식성을 위해서 %Id가(64만 빼고) 별도로 추가되었지만, 이건 반대로 구형 CRT에서는 지원되지 않는 걸로 알고 있다.
그래도 다행인 건, binary format이 아니라 사람이 읽을 수 있는 문자열 형태로 숫자를 읽고 쓰는데 32비트 크기를 넘어서는 범위를 다루는 일은 굉장히 드물다는 것. 차라리 실수를 다루면 다뤘지 정수가 그러는 일은 드문 편이다.

참고로, 가변 인자 함수가 호출될 때, 모든 정수형은 기본적으로 int 형으로 promote가 일어난다. char이든 short이든 다 32비트 내지 64비트 크기로 폭이 증폭된다는 것이다. float는 double로 바뀐다. 그렇기 때문에 float나 double이나 동일하게 %f나 %g로 출력 가능하다. 단지, 값이 아니라 포인터가 전달되는 scanf를 호출할 때는, float에 대해서는 %f를, double에 대해서는 %lf라고 반드시 타입 구분을 엄격히 해 줘야 할 것이다.

64비트 정수를 전달할 때는 32비트 기계에서는 스택에 push가 두 번에 걸쳐 일어나지만, 본디 64비트이던 기계에서는 역시 한 번만에 인자 전달이 끝난다. 그렇기 때문에 %d %d %d 해 놓고 실제로 32, 64, 32비트의 순으로 변수를 전달했다면 32비트 기계에서는 마지막 숫자가 꼬이겠지만(push는 128비트, 하지만 pop은 96비트) 64비트는 둘째 정수가 범위만 32비트 내부에 있다면 세 숫자가 모두 제대로 출력이 된다(push와 pop 모두 64*3비트 동일).
물론 이 경우, 둘째 %d를 %I64d로 해 줘야 32와 64비트 기계에서 모두 잘 동작하는 portable 코드를 만들 수 있다.

윈도우 외의 다른 운영체제는 사정이 어떤가 모르겠다. 64비트 정수를 출력할 때 32비트 기계에서는 %lld, 심지어 64비트에서는 %ld 이렇게 차이가 존재한다고도 하는데.. =_=;;
gcc 자체가 I64와는 다른 관행을 사용하는데 기계마저 64비트로 가고 나면 이거 이식성 면에서 재앙이 발생하지는 않으려나 우려된다.

다음으로 문자열이 있다.
알파벳 이외의 문자는 다룰 일이 없는 서버나 게임 엔진 같은 아주 특수한 프로그램을 개발하는 게 아니라면 이제 유니코드 문자는 대세가 되어 있다. 물론 UTF8도 쓰이고 유닉스 계열 운영체제에서는 심지어 UTF32도 쓰이지만, 그래도 유니코드 문자열을 컴퓨터 메모리 상으로 저장하는 데 비용 대 효율이 가장 뛰어난 방법은 UTF16이다. 특히 윈도우는 NT 시절부터 이렇게 16비트 단위의 wide string을 내부적으로 다뤄 와서 wchar_t = 곧 unsigned short나 다름없을 정도이다.

printf는 ansi 버전과 wide 버전이 존재하며, format으로 지정해 주는 문자열과 %s로 전달하는 문자열의 타입은 대체로 일치한다. ansi 버퍼에다가 wide string을 출력한다거나 그 반대로 해야 하는 경우는 드물다. 하지만 그런 일이 아주 없는 것은 아니다.

이럴 때 윈도우에서는 %hs, %ls라는 지정자를 주어 h는 버퍼 크기와 상관없이 무조건 ansi, l은 버퍼 크기와 상관없이 무조건 wide라고 지정을 할 수 있게 했다. gcc 쪽은 잘 모르겠다.

그래서 함수 오버로딩이 지원되는 C++ 스트림이 짱이라는 말이 있을 정도이니까. 아무 곳에서나 그저 무조건 OK.

Posted by 사무엘

2010/01/11 10:15 2010/01/11 10:15
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/90

C++의 멤버 포인터

C++에는 pointer-to-member이라는 아주 기괴한 개념이 있다.

마치 일반 변수를 포인터로 가리키고 전역 함수를 가리키는 포인터가 있는 것처럼,
이 포인터는 포인터이긴 한데, 변수 포인터라면 특정 클래스(=구조체)에 대한 오프셋에 매여 있다고 볼 수 있으며, 함수 포인터라면 특정 클래스의 멤버 함수로 소속된 함수만 가리킨다는 특수성을 지닌다.

그래서 한 클래스 안에 프로토타입이 일치하는(리턴값, 인자의 개수와 type들) 여러 멤버 함수들이 있으면 그것들 중 하나를 가리켜서 특정한 한 함수를 if나 switch..case 없이 계속 가리키면서 호출하게 할 수 있다. 이걸 이용해서 얼추 다형성까지 구현할 수 있다는 것이다.

또한 한 클래스 안에 동일한 타입을 지닌 여러 멤버에 대해서 일괄적으로 초기화를 해 줘야 할 때, 멤버 함수를 가리키는 포인터를 써서 이 일을 아주 generic하고 수월하게 할 수 있다.
MFC에서 message map도 매크로를 파헤쳐 보면 응당 pointer-to-member를 써서 구현되어 있다.

C++에서 추가된 클래스 멤버 함수는 굉장히 묘한 존재이다.
사실, C에서도 구조체에다가 함수 포인터를 갖다 둠으로써 obj.func() ptr->func() 같은 문법 자체가 불가능한 것은 아니다. 그런데 이 경우 ()를 생략함으로써 해당 함수의 주소를 간단하게 얻을 수 있은 반면, C++ 멤버 함수는 그렇게 할 수 없다. 그 멤버 함수는 그 클래스의 '인스턴스'에 소속되어 있으면서 그 클래스의 크기에 영향을 주는 존재가 아니기 때문이다. scope이라는 개념이 존재하고 this 값을 기본으로 받는 것만 빼면 위상은 오히려 전역 함수에 더 가깝다.

아예 개체와 함께 함수 호출 연산자 ()를 줘서 확실하게 호출을 하든가, 아니면 &Class::Function과 같은 형태로 pointer-to-member 값을 얻어 와서 자기 타입에 맞는 pointer-to-member에다가 그 주소를 대입만 수 있다. 일반 함수 포인터와는 달리 이렇게 얻어진 값은 곧바로 함수 호출을 할 수 없으며, 반드시 그 클래스에 해당하는 개체가 있어야 한다. 개체 뒤에다 .* 이라든가 ->* 연산자를 붙이면 되는데, 이때 .*나 ->*은 *까지 붙어서 완전히 한 연산자 토큰이기 때문에 둘 사이에 공백이 들어갈 수 없다.

또 하나 주의해야 하는 것은 C++의 연산자 우선 순위이다. (obj->*pfnFunc)(함수인자)처럼 함수 인자 앞에는 반드시 괄호가 들어가야 한다. 또한 우리 클래스 안이라고 해도 ->*나 .* 앞에서는 this를 생략할 수 없으며 반드시 붙여야 한다. 여러 모로 문법부터가 기괴하다는 것이다.
이 pointer-to-member의 타입은 공식적으로 void (Class::*)(함수인자) 이라 불린다. 이때, ::와 *은 서로 독립된 토큰이다.

그런데 pointer-to-member하고 가상 함수가 서로 만나면 이거 정말 골치아픈 문제가 발생하고 만다. 둘 다 다형성을 위해 존재하는 기능이며, 둘 다 구현하기 위해서 컴파일러가 프로그래머가 모르게 암시적으로 몰래 하는 일이 상당히 많은 기능들이다. 하지만, 결론부터 말하자면 이 두 기능은 서로 연동해서 사용할 수 없다. 이게 무슨 말인지를 지금부터 설명하겠다.

A라는 클래스가 있고 f라는 가상 함수를 정의했다. 그 후 A로부터 상속 받은 B라는 클래스는 f라는 가상 함수를 또 오버라이드했다.

그 후 A의 포인터 pf에 어떤 값이 들어왔다. 얘가 가리키는 값이 실제로 A인지, 아니면 A의 파생인 B인지는 알 수 없다.
이때,

if( &pf->f == &A::f ) puts("pf 개체는 A 타입");
else if( &pf->f == &B::f ) puts("pf 개체는 B 타입");

와 같은 형태로, 이 개체에 소속된 가상 함수의 주소를 판단함으로써 그 개체의 원래 타입을 판별할 수 있을까? 마치 C 언어 구조체의 멤버로 들어있는 함수 포인터를 비교하듯이, 가상 함수와 pointer-to-member 주소를 이런 식으로 연계할 수 있을까?

그럴 수가 없다는 뜻이다.
일단 클래스 멤버 함수의 pointer-to-member 주소를 되돌리는 방법 자체가 pf 같은 동적 바인딩을 허락하지 않는다.

그리고 더욱 나쁜 사실은, &A::f와 &B::f의 값 자체가 실제로 확인해 보면 동일하다는 것이다!
어떤 A 타입의 포인터가 있는데, pointer-to-member를 이용하여 어떨 때는 강제로 A::f를 호출하고, 어떨 때는 일부러 B::f를 호출하여 기반 클래스와 파생 클래스를 구분하는 것 자체가 불가능하다. 한 클래스 안에서 프로토타입이 같은 여러 수평적 함수들을 구분할 수는 있으나, 부모와 파생 클래스 사이의 수직적 관계가 존재하는 가상 함수는 전혀 구분할 수 없다.

사실은 이것이 원래 의도했던 C++ pointer-to-member의 동작 스펙이기도 하다. 가상 함수와 일반 함수를 구분하지 않고 그 위 계층에서 동작하게끔 말이다.

C++에서 가상 함수가 어떻게 구현되는지 대충 아는 분들도 있겠지만,
가상 함수가 생기면 사실 해당 클래스에 가상 함수들의 포인터가 당장 쭉 추가되는 게 아니라, 해당 클래스를 나타내는 가상 함수 포인터 배열이 하나 생기고, 이를 가리키는 '포인터'가 하나 추가된다. 즉,

ptr->nonVirtual(...) ==> globalFunc(this, ...) 가 일반 함수라면, 가상 함수는

ptr->virtualFunc(...) ==> ptr->vtbl->pfn[n](this, ...) 처럼 전개된다는 뜻.

그런데 pointer-to-member 함수는 매 타입에 대해서 vtbl의 값을 저장하고 있는 게 아니다.

virtualFunc_stub(this, ...)
{
        return this->vtbl->pfn[n](this, ...)
}

가상 함수의 address란 바로, ptr에 대해 가상 함수 테이블을 뒤져서 실제 가상 함수를 호출해 주는 "껍데기 함수"의 주소로 정적으로 고정되는 것이다! &A::f이든 &B::f이든 가리키는 주소는 바로 저기가 된다는 것이다. 이 일을 컴파일러가 몰래 슬쩍 해 준다.

그렇기 때문에 pointer-to-member 함수는 함수의 프로토타입만 일치한다면 일반이든 virtual이든 전혀 구분 없이 함수를 가리킬 수 있으며,
(pp->*pfn)(); 와 같은 문장은

004010C8 8B CE            mov         ecx,esi
004010CA FF D3            call        ebx

가리키는 대상이 virtual이든 아니든 관계없이 위와 같은 두 명령으로 간단히 번역된다. 실제 가상 함수 호출은 저 껍데기의 내부에서 이루어지니까.
pp->SayA(); 처럼 대놓고 가상 함수를 호출할 때는

004010CC 8B 16            mov         edx,dword ptr [esi]
004010CE 8B 02            mov         eax,dword ptr [edx]
004010D0 8B CE            mov         ecx,esi
004010D2 FF D0            call        eax 

테이블을 참조하는 오버헤드가 해당 코드에서 바로 이뤄지는 것과 좋은 대조를 이루는 셈이다.

따라서 이 함수가 어느 가상 함수를 가리키는지는, 문서화되지 않은 컴파일러 꽁수라든가 어셈블러 같은 지저분한 방법을 동원하지 않고서는 알 수 없다는 것이 결론이 되겠다. pointer-to-member의 동작은 가상 함수의 영향을 받지 않는다.

그런데, 가상 함수는 그나마 이런 식으로 극복해서 일관성을 얻었다지만, 가상 상속, 다중 상속 같은 극악한 C++만의 기능과 마주치면(요즘 언어들은 채택도 안 하고 있는), pointer-to-member 구현의 복잡도는 그야말로 GG 치는 경지에 다다른다.

결국 자기가 가리키는 함수뿐만 아니라 기반 클래스처럼 그 클래스와 관련된 다른 정보도 들어가야 하기 때문에, machine word(통상 4 또는 8바이트) 단위 크기보다 크기가 더 커지게 된다! this 포인터도 막 왔다갔다 해야 하므로.
그런 클래스에서 멤버 포인터가 구체적으로 어떻게 구현되며 잉여 데이터가 어떻게 활용되는지는 본인도 잘 모르겠다.. -_-;; () [] * 가 막 뒤섞인 복잡한 타입을 바로 읽고 쓰는 것만큼이나 도저히 이해를...;;;

더구나, forward 선언만 해 놓은 클래스인 경우, 이 클래스의 pointer-to-member는 4바이트 크기로만 잡아도 될지, 아니면 더 커야 할지를 알 수 없기 때문에 일단 최악의 경우를 가정하여 엄청 큰 사이즈가 잡힌다. 같은 포인터가 클래스 몸체의 선언 전과 후에 계산되는 크기가 달라진다는 뜻이다. (차라리 몸체가 없는 클래스의 멤버 포인터는 크기를 계산할 수 없다고 컴파일 에러를 내뱉는 게 더 낫겠다 -_-)

pointer-to-member는 꼭 필요는 하지만 이렇게 지저분한 존재가 되어 있다.
C++ 다음 표준에서는 이것도 뭔가 문법이 바뀔 거라는 식으로 들은 것 같기도 한데 잘 모르겠다. 가령, 이제 true, false도 꽤 오래 전에 정식 예약어로 추가가 됐는데, 0말고 NULL 포인터만을 가리키는 nil 같은 키워드도 type-safety를 위해서라면 좀 있어야 않나 싶다. 사실, C++이 오버로딩이 가능해지면서 타입 구분 강화 쪽으로 개선이 많이 되어 왔으며, explicit도 type-safety 보장을 위해서 추가된 키워드이다.

차라리 함수 포인터는 껍데기 함수 만들지 말고 그 함수 실체만 바로 가리키게 하면 영 안 되려나?? 지금처럼 해 놓은 이유가 있는지 잘 모르겠다.

Posted by 사무엘

2010/01/11 10:13 2010/01/11 10:13
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/88

- 클래스 이름과 파일 이름이 반드시 일치함이 보장되니, 소스 navigation이 은근히 편하다. 그리고 각 클래스 내부에 static void main 함수만 구현해 주면, 그 클래스만 용도를 테스트하는 프로그램을 간단히 짜고 그 유닛 단위로 실행이 가능하니 무척 편하다.
- 클래스에 각종 명칭의 선언과 정의가 따로 구분되어 있지 않으며, forward 선언이라든가, 온갖 dependency 따지기, 재컴파일 같은 지저분한 튜닝이 자바에는 필요하지 않다. 링크 에러라는 개념도 자바에는 존재하지 않는다. ㅋㅋ

- 모든 오브젝트들에 무조건 RTTI 정보가 들어있어서 type을 알 수 있다.
- 프로그램이 뻗으면 자동으로 함수 호출 스택 목록과 줄번호가 다 뜬다.

물론, 자바의 장점들 중 구조적인 것 말고 프로그램 실행과 관련된 편의는, 대부분 C/C++에 비해 성능을 상당히 희생하고서 얻어진 것임은 의심의 여지 없는 사실이다.

그래서 남이 짠 코드를 분석하고 들여다보고 유지보수 해야 할 때, 자바가 적응만 잘 돼 있으면 생산성이 상당히 높겠다는 점을 인정한다.
C/C++은 정말 변태스러운 튜닝과 자유도를 추구하는 대신, 그 코드가 여러 사람의 손을 거치면서 무질서도의 증가로 이어진다면 maintenance 측면에서 재앙이 될 수도 있다. 복잡한 암호 같은 C++ 코드에서 메모리 누수 하나 찾아 보시겠는가?

C/C++은 각 기계의 특성을 일일이 수용하고 존중해 준다는 점에서 이식성이 높다. 조건부 컴파일, 공용체, 포인터, 온갖 복잡한 컴파일러/링커 옵션 등등등...
하지만 자바나 C#급 언어는 그런 기계스러운 건 숨기고 포인터를 감싸고 특히 C/C++의 야생마스러운 면모를 적당히 제어하면서, 그 언어를 돌리는 플랫폼 자체를 이식성 있게 여럿 만듦으로써 이식성을 추구한다고 볼 수 있다. (자바 가상 머신, 닷넷 프레임워크 등) 즉, 언어의 근본 설계 철학과 용도가 다르다.

C++은 virtual로 지정된 놈만 가상 함수인 반면,
자바는 final이 지정되지 않은 다른 모든 놈이 기본으로 다 가상 함수이다.
가상 함수의 구현 비용이 만만찮은데, 이런 발상의 전환이 어떻게 이뤄졌는지 대단하다.

C/C++은 static이 무척 의미가 다양한 키워드인데 이는 자바도 어느 정도 이어받고 있다.
그런데 자바에만 있는 키워드로 final이 있는데, 얘가 일종의 const 역할도 하고 비가상함수임도 나타내니 문법이 무척 기발하다.

끝으로, 자바에서 아쉬운 것을 꼽자면,

- 조건부 컴파일이 안 되고, 특정 코드를 #if 0 ... #end if 이렇게 간편하게 막아 버리는 방법이 없다. -_-
- C 스타일 %d, %s로 간편하게 스트링을 포맷하는 방법이 없나? (디버그 로그 찍을 때 필요)

- 나는 자바로 범용적인 swap 함수를 어떻게 만드는지 아직도 전혀 모른다.
int a=3, b=5; swap(a,b); 이렇게 할 수가 없나? (자바에는 템플릿도, 매크로 함수도 없으며, int는 무조건 call by value로 전달됨)

Posted by 사무엘

2010/01/11 10:11 2010/01/11 10:11
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/87

C++, 템플릿 이야기

오늘날 소프트웨어 업계에서 네이티브 기계어 기반 프로그램을 개발, 빌드하는 데 가장 널리 쓰이는 언어는 단연 C++이다.
C++은 잘 알다시피 C언어로부터 파생되어 C의 호환성을 유지하면서 거기에 OOP 요소를 가미한 매우 복잡한 언어이다. 클래스와 this 포인터, 생성자/소멸자. 상속, 정보 은닉, 다형성 따위는 언뜻 C처럼 보이는 코드의 함축성과 표현력을 월등히 끌어올려 주었다.

C++이 처음 등장한 것은 1983년이지만, 당대의 주류 컴퓨터 성능에 비해서 컴파일러 구현의 난해함, C++의 개념을 구현하기 위한 비용에 대한 논란 등등 때문에 실제로 C++이 업계에서 널리 쓰이기 시작한 것은 최소한 90년도가 지나서이다.

C++도 일종의 순수주의자의 관점에서 보자면 지저분한 특성, 욕 얻어먹을 면모가 굉장히 많다. 마치 윈도우라는 운영체제처럼.
하지만 상업적으로 성공하는 제품은 역시 무조건 성능이 우수한 것보다는, 현실과 이상을 적당히 잘 절충하고 대중화를 잘 한 녀석이라는 사실은 프로그래밍 언어라는 바닥에서도 적용되는 게 틀림없다.

오늘날 C++ 말고 동급 용도의 다른 어떤 언어에도 존재하지 않으며 앞으로도 차용되지 않을 기능을 뽑자면 아마 전처리기와 다중 상속이 아닌가 싶다. 강력한 만큼 괴악하고 폐단(?)이 심하기도 한 기능이어서이다. 요즘은 조건부 컴파일의 범위를 넘어서는 전처리기/매크로 기능은 거의 빠지는 추세이고 다중 상속도 인터페이스라는 다른 개념으로 대체되고 있다. 자바, C#, D 같은 언어의 스펙을 보면 그 alternative를 알 수 있다.

C++은 첫 등장한 후에도 꾸준히 변화해 왔다.
90년대 이후에는 템플릿이라는 어마어마한 기능이 추가되었으며
기능상으로 없던 게 추가된 건 아니지만, type-safety 강화 및 모호성 발생 예방을 위해서 특별히 취해진 조치도 상당하다. 가령, static_cast 같은 장황한 형변환 연산자가 추가되었으며 bool, wchar_t 같은 타입이 built-in으로 추가되었다. explicit도 이런 차원에서 추가된 키워드이다.

namespace는 각종 명칭들의 scope을 C와 같은 2차원적인 평면이 아닌 3차원적인 입체 계층으로 관리하게 해 준 엄청난 기능인 한편으로 C++ 컴파일러 구현의 난해성을 더욱 올린 기능이 아닌가 싶다. 컴파일러 개발자 내지 컴파일러를 돌리는 컴퓨터가 고생하는 만큼 프로그래머는 더 편해지고 프로그램을 유지 관리하기가 더 수월해지는 셈이다.

얼마 전엔 꽤 흔치 않은 개념을 코딩해야 할 일이 있었다.
템플릿 클래스 안에 static 멤버가 있었고, 이 멤버는 그 클래스가 자체 정의하는 구조체를 사용하고 있었다. 그래서 이 static 멤버를 클래스 바깥에서 초기화를 해 줘야 했는데, 그 멤버의 type을 클래스 바깥에서 표현이 제대로 되지 않고 자꾸 컴파일 에러가 나는 것이었다.

template<typename T>
class A {
        struct B {
        };
        static B data;
};

template<typename T>
A<T>::B A<T>::data;

(1) C++ 초창기.. 그러니까 한 터보 C++ 1.0 시절에는 static 데이터 멤버를 이렇게 바깥에서 정의 안 해 줘도 괜찮았다. 하지만 스펙이 바뀌어서 정의를 안 하면 링크 에러가 나게 나중에 바뀌었다.

(2) 또한 typename이라는 키워드도 C++에 템플릿이 추가되고 나서 한 박자 뒤에 표준화되어 90년대 중반에 도입된 것이다. 예전에는 class T만 가능했다가 문맥상 혼동을 없애기 위해 나중에 추가되었다.

어쨌든.. A라는 클래스가 템플릿이 아니거나,
혹은 data의 타입이 저런 자체 구조체가 아니라 전역 scope로 존재하는 다른 구조체 내지 그냥 built-in 타입이었다면.. 저렇게 선언하는 건 정말 일도 아니다.
그리고 솔직히 템플릿 클래스에다가 저런 식의 멤버를 만드는 일은 굉장히 희박한 것도 사실이다.

템플릿 클래스 내부의 자체 구조체로 선언된 static 멤버를 정의하는 방법은 아무리 생각해도 저것밖에 없는데 컴파일러가 도무지 말을 안 들어서 한 30분을 삽질했다.
그런데 문제 해결 방법은 기괴했다. typename을 앞에 추가하면 되더라..;;

template<typename T>
typename A<T>::B A<T>::data;

템플릿은 컴파일러가 실제로 생성해 내는 그 코드 자체가 아니라, 나중에 코드를 생성하는 틀, 말 그대로 템플릿에 불과하기 때문에 C++ 컴파일러에 템플릿이 첫 도입되었던 초창기엔 디버깅도 굉장히 어렵고, 실제로 템플릿을 A<int>처럼 인스턴스화해서 사용해 보기 전엔 컴파일 에러 자체도 잡아내기가 쉽지 않았다. 더구나 템플릿 클래스의 각종 구현부도 소스 파일이 아니라 헤더 파일에 다 선언되어 있어야 하기 때문에 한계가 많으며, 최적화 성능도 시원찮아서 code bloat의 주범이라고 욕도 먹었다. int형 코드 따로, short형 코드 따로 등등등.

하지만 지금은 상황이 많아 나아져서, 적당히 비슷한 타입으로 여러 템플릿을 사용하더라도 어지간한 건 void *형 포인터로 C 문법으로 general하게 코드를 생성할 정도로 컴파일러도 많이 똑똑해졌다. compile-time뿐만 아니라 link-time에 코드를 생성하고 한 obj 파일뿐만 아니라 여러 obj 파일 사이의 전역적인 최적화를 수행하는 기술이 도입된 것도 템플릿의 처리에 무척 유리하다. 압축으로 치면 RAR의 솔리드 압축 기능뻘 된다. 비주얼 C++은 6.0은 이런 기능이 없고, 200x닷넷급에서 최초로 이런 게 도입되었다.

C는 일단 그 가벼움과 C 특유의 이식성 때문에 영원히 절대 없어지지 않을 언어이다. (그 정도 고급 언어 중에 공용체, 비트 필드가 존재하는 언어가 또 무엇이 있을까? =_=;; C에 비트 회전 연산자가 없는 게 이상하다.)
그에 반해 C++은 동급이면서 더 깔끔하고 생산성 뛰어나고 GC(누수 메모리 자동 회수)까지 지원하는 다른 차세대 OOP 언어로 먼 미래에 대체될 수 있을 수도 있겠다는 생각이 들기도 한다. 특히 클라이언트에서 네이티브의 비중이 낮아질수록 지위가 역전되는 시기는 더욱 일러질지도 모르겠다.

윈도우 운영체제가 전통적으로 C언어식 API를 제공해 왔다면, 닷넷은 C# 기반이기도 하다.
하지만 20여 년에 가까운 컴퓨터 역사상, 무수히 쌓인 C/C++ 코드의 쪽수와 짬밥이 쉽사리 역전될 것 같지는 않기도 하고.. 미래를 예측하기란 참 어렵다. ^^;;

Posted by 사무엘

2010/01/11 09:40 2010/01/11 09:40
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/64

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/09   »
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          

Site Stats

Total hits:
1251704
Today:
322
Yesterday:
522