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

Trackback URL : http://moogi.new21.org/tc/trackback/529

Comments List

  1. 주의사신 2011/06/22 08:55 # M/D Reply Permalink

    1. 졸업 작품 만들 적에 변수 초기화를 하나 안 했더니, 디버거에서는 돌아가다가, 실제로 실행하면 안 되고, 또 어떻게 빌드하면 디버거에서는 안 돌아가고, 실제 실행하면 돌아가고....

    이 문제의 원인을 찾느라 한 나절 반 정도 썼던 것 같네요.

    그리고서 내린 결론.

    "버그는 운이나 우연에 의해 생기는 것이 아니라 잘못된 개발 습관에 의한 필연적인 결과이다."


    2. 들어보셨겠지만, printf 같은 것을 사용했을 때 버그가 사라지는 것을 '하이젠버그'라고 합니다. 원자 등을 다루는 세계에서는 빛을 쏘면 광자랑 입자가 충돌해서 그 성질이 변하기 때문에 잘 관찰하기 어렵다는 '하이젠베르그의 불확정성 원리'에서 출발한 용어이지요.


    3. 졸업 작품 프로젝트 할 적에는 Debug 빌드 자체를 없애 버렸답니다. Lua Dll의 최적화 레벨(3)과 프로그램의 최적화 레벨(2)가 충돌해서 버그를 만들어내더군요. 이거 해결하려면, 모든 Dll의 Debug버전을 구해야 하는데, 이건 아무리 생각해도 아닌지라....


    4. Release 버전 빌드 시에는 다음 프로그램을 사용해 주면 좋습니다.

    http://ospace.tistory.com/113

    DbgView라는 프로그램인데요. OutputDebugString의 내용을 보여 줍니다.

    1. 사무엘 2011/06/22 14:13 # M/D Permalink

      의견 감사합니다. ^^

      1. 초기화되지 않은 변수는 정말 만악의 근원입니다. 배열이나 구조체 멤버는 컴파일러가 제대로 체크도 못 하기 때문에 상황이 더욱 심각하죠. 초기화되지 않은 변수라는 개념 자체가 존재하는 언어가 C/C++말고 또 있을까 싶습니다.

      2. 하이젠버그 맞습니다. 뭔가 사고가 나면 일단 현장 보존이 최우선인데... 저건 read-only operation조차 상태를 바꾸는 operation으로 바꾸면서 개발자를 더욱 패닉에 빠뜨리는 상황이죠.

      3. 모듈간에 debug와 release configuration이 충돌하는 건 보통 C 라이브러리의 충돌 때문인 경우가 많습니다. debug CRT를 쓰는 DLL이 malloc해서 넘겨 준 포인터를 release CRT를 쓰는 EXE가 free하는 경우.. 더 말이 필요 없죠. 이것도 엄밀히 말해서 그리 좋은 디자인이 아니며, DLL은 자기가 쓰는 메모리 할당/해제 함수를 외부에도 공개해야 할 것입니다.

      4. 사실, 윈도우 NT 계열에서도 OutputDebugStringW는 내부적으로는 버퍼를 ansi로 바꿔서 A 함수를 호출한답니다. 따라서 디버깅 로그에는 원래 유니코드 문자가 표기되지 않습니다.

  2. 김 기윤 2011/06/22 16:47 # M/D Reply Permalink

    1. 초기화되지 않은 변수-_-로 피해를 입은 적인 한두번이 아니다보니 이쪽은 아예 도가 터버렸습니다.

    2. Debug모드와 Release 모드의 속도차이-_-로 발생한 버그는 저도 겪어본 적이 있습니다. 프로그램이 실행 된 뒤 경과 시간을 얻어내는 함수를 사용해서 그 값을 Rand의 seed 로 주는 코드에서 문제가 발생했었는데, debug모드는 잘 되는데 release모드에서 안 된 이유가 하필이면 그 seed 로 0이 들어갔는데, 0 에서 문제가 발생하는 rand 코드였던...

    1. 사무엘 2011/06/23 00:54 # M/D Permalink

      C/C++로 본격적인 프로그램 개발을 해 본 분이라면 본문 내용에 정말 공감할 겁니다. =_=
      이거 다음으로는, DLL과 EXE 사이에 함수 calling convention이 일치하지 않아서 괴상한 버그가 발생하던 게 저의 기억에 남습니다.

  3. 김 기윤 2011/06/23 16:28 # M/D Reply Permalink

    그러고보니 꽤 유니크한 경우로 Debug에서 빌드가 안되고 Release 에서 빌드가 되던(컴파일은 양쪽 다 되는데 Debug쪽에서 링킹 거부) 괴상한 상황이 있었는데, 나중에 알고보니 사용한 라이브러리는 Multithread-Debug 인데 현재 프로젝트에서는 Multithread-Debug DLL 의 불일치로 인해서 생긴 링킹거부였던 적도 있습니다. 다만, 원인을 알기 전까지는 1주일가량 삽질의 삽질을 거듭했었죠. orz

    1. 사무엘 2011/06/23 23:36 # M/D Permalink

      http://minjang.egloos.com/2146607 참조.
      말씀하신 것처럼, C 라이브러리의 사용 형태(특히 static이냐 DLL이냐)가 모듈간에 꼬여도 정말 무진장 골치 아프죠.
      링크 에러는 컴파일 에러와는 달리 행번호 정보도 안 나오니 더욱 난감합니다.
      소스 코드뿐만이 아니라 프로젝트 설정이나 컴파일 옵션이 잘못되어 나기도 하는 에러이니까요.

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/521

Comments List

  1. 아라크넹 2011/06/06 00:02 # M/D Reply Permalink

    엄밀하게 말하면, A == 100 ? B : C = 200은 ISO C에서도(ISO/IEC 9899:1999 section 6.5.16) ISO C++에서도(ISO/IEC 14882:1998 section 5.17) 아예 파싱이 되지 않아야 옳습니다. 문법을 잘 살펴 보시면 알 수 있듯 =의 왼쪽에는 괄호 없이 삼항 연산자 수식이 나타날 수 없습니다. 비주얼 C++의 동작은 아마도 확장인 것 같습니다.

    한 가지 흥미로운 것은 ISO C에서는 a || b = c가 파싱이 되지 않는 반면(즉, = 왼쪽에는 단항연산자 수식만 올 수 있습니다) ISO C++에서는 a || b = c가 (a || b) = c로 파싱이 된다는 것입니다(즉, 삼항 연산자를 뺀 모든 수식이 문법상으로는 올 수 있습니다). 후자는 operator||를 재정의해서 비상수 레퍼런스를 반환하게 하면 컴파일도 제대로 될 거에요.

    1. 사무엘 2011/06/06 15:06 # M/D Permalink

      프로그래밍 언어와 IT 표준의 전문가께서 한마디 해 주니 감사! ㄲㄲ
      연산자에 대해서 단순히 우선순위 이상으로 언어가 규정하고 있는 게 더 있군요.
      그래도 설마 && ||를 오버로드하는 용자가 있겠나 싶습니다.

  2. 주의사신 2011/06/06 08:47 # M/D Reply Permalink

    java 처음 입문할 적에, Robocode( http://robocode.sourceforge.net/ )라는 것을 했던 적이 있습니다. 이 때 미리 만들어진 다른 사람의 Robot을 구경하는데, 이 사람 ?를 정말 사랑하더군요.

    (a ? ( b ? c : d) : ( e ? f : g))라는 코드가 존재할 정도...

    그냥 if문 쓰지 하는 생각이 들 정도였습니다.

    1. 사무엘 2011/06/06 15:06 # M/D Permalink

      저는 코딩 스타일이 좀 구려서-_-, ? :와 ,의 남발을 좋아합니다. ㅋㅋㅋ

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/485

Comments List

  1. 김기윤 2011/03/25 13:29 # M/D Reply Permalink

    지난학게 모바일 프로그래밍 시간때 아주 잠깐.... 써봤는데, 한 학기동안 배우니까 언어 자체는 꽤 괜찮다고 느꼈습니다. 특히나, XCode 의 프로그래머 편의의 기능이라던가.. 하는 쪽은 Visual Studio 를 압도하더군요.

    뭐, 한학기동안 잠깐 하고 말았던 것이긴 하지만, 다음에 기회될때 마저 배워도 괜찮은 언어라고 생각합니다.

  2. 박철현 2011/03/25 16:50 # M/D Reply Permalink

    오브젝트 C를
    우리나라에서 가장 먼저 시작하신 분으로
    김민수 사장님이 있습니다.

    Mlayout을 개발하신 분입니다.
    (Mlayout 프로그램은 쿽익스프레스 기능을 수행하는 프로그램입니다.)

    만나 뵙고 싶다면 ...
    꼭 소개를 해드리고 싶은 마음이 있습니다.

  3. 사무엘 2011/03/26 01:06 # M/D Reply Permalink

    김기윤: 아, 모바일 프로그래밍 들으셨댔죠. 나름 XCode까지 다루고 좋은 경험 하셨네요. ^^
    옵C는 아무래도 C++보다야 훨씬 더 단순하고 분석하기 쉬운 언어일 테고, 그만큼 IDE가 더 편리한 기능을 제공해 줄 수도 있을 것 같습니다.

    박철현: 맥 환경 소프트웨어 개발의 최고 선구자이시라는 얘기군요. 알려 주셔서 고맙습니다.

  4. 김재주 2011/03/27 05:18 # M/D Reply Permalink

    의외로 넥슨 게임들이 ObjC를 많이 썼습니다. 마비노기를 기점으로 C/C++이 늘어났다고 알고 있는데요...

    오브젝티브C의 메서드 호출 방법은 가상 함수랑 비슷합니다. 어떤 객체에 대해서 메서드를 호출하면 그 메서드 이름을 찾아서 메서드 핸들러를 호출하는 것이죠. 메서드 이름은 동적으로 관리하고요. 그렇다보니 어떤 객체가 소유하고 있지 않은 메서드도 호출은 할 수 있습니다. 이게 상당히 편리한 특성이라 루비 같은 동적 언어들도 이런 특성을 가지고 있죠

    1. 사무엘 2011/03/27 23:28 # M/D Permalink

      오, 윈도우 환경에서도 옵C로 개발된 프로그램이 있었나 봐요?
      말씀하신 대로, 옵C의 멤버 함수는 이름과 매개변수가 동적으로 관리됩니다. 어차피 C++로도 바이너리 호환성을 유지하면서 유연하게 클래스 라이브러리의 기능을 확장하려다 보면 UINT nMsg, PVOID pArg 같은 식으로 generic, polymorphic한 함수로 메시지를 주고받게 되지요. 그에 비하면 C++은 템플릿도 그렇고 여전히 컴파일 타임 때 결정되는 요소가 많은 딱딱한 언어라는 느낌이 듭니다.

  5. rubis 2012/01/04 15:14 # M/D Reply Permalink

    사무엘님 그래도 맥용 날개셋이 나오길 학수고대!!! (실현되지못할꿈이라도) 하고 있는 사용자입니다. ^^

    1. 사무엘 2012/01/04 18:52 # M/D Permalink

      저도 꼭 그렇게 됐으면 좋겠습니다. ㅜ.ㅜ ^^;;

Leave a comment

우리는 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

Trackback URL : http://moogi.new21.org/tc/trackback/412

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/332

Comments List

  1. 삼각형 2010/07/28 14:17 # M/D Reply Permalink

    new와 malloc에 관계에 대해 생각해 본 일이 없었는데 그런거군요. C와 C++은 다른 언어라고 생각하고 보는지라.

    new/delete, malloc/free를 섞어 사용해도 작동하는군요.

    마지막 문단에
    new로 할당한 메모리를 delete로 해제...
    에서 delete가 아니라, free아닌가요?

    1. 사무엘 2010/07/28 21:27 # M/D Permalink

      예. 어차피 메모리 할당하고 해제하는 건 똑같고, C++ 라이브러리는 C 라이브러리에 의존성이 있는 형태이니까요.
      그리고 free가 맞습니다. 저의 실수군요. ^^ 고쳤습니다.

  2. 김재주 2010/07/29 01:06 # M/D Reply Permalink

    섞어 쓰면 문제가 될 가능성이 꽤 크죠. 일단 생성자와 소멸자라는 C++ 객체 시스템의 근간을 뒤흔드는 짓이니까요. 어리석은 행동이 될 가능성이 매우 높습니다.

    1. 사무엘 2010/07/29 15:18 # M/D Permalink

      생성자/소멸자나 가상 함수가 있는 개체를 malloc/free질 하는 건 당연히 있을 수 없는 일이고요.
      순수하게 메모리 할당/해제만 하면 되는 기본 자료형이나 구조체만으로 한정해도 C 방식과 C++ 방식의 혼용은 비추라는 것입니다. ^^

  3. 김기윤 2011/01/20 14:16 # M/D Reply Permalink

    디버깅할때 new 문장 에서 step into 하면 들어가는 void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc) 이 그거군요!

    평소에는 제대로 쳐다보지도 않았는데, 자세히 보니 malloc 사용된게 보이네요!

    그런데, delete 에서는 free가 사용되었다는 예측은 가지만, step into 해도 따로 함수로 들어가지는 않네요.

    1. 사무엘 2011/01/20 17:47 # M/D Permalink

      아무래도 delete 연산자에 대해 step in을 하면, 해당 개체의 소멸자 함수로 들어가지, operator delete 안으로 들어가는 건 딱히 못 본 것 같습니다. 그래도 heap 쪽에서 에러가 나면, 디버거가 operator delete 안에서 멈춰섰다고 안내는 잘 해 줍니다. ^^

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/313

Comments List

  1. 주의사신 2010/07/07 09:04 # M/D Reply Permalink

    C++ namespace에서 딱 하나 아쉬운 것이요.

    namespace를 3중 정도 중첩한다고 했을 때,

    namespace A::B::C {
    //어쩌고 저쩌고
    }

    가 불가능하다는 것이 아닐까 합니다.

    너무 안으로 들어가서 보기 어렵죠...

    C#은 이것을 지원해서 좋긴한데...ㅜㅜ

    1. 사무엘 2010/07/07 13:01 # M/D Permalink

      아.. 원큐에 중첩 namespace를 한꺼번에 만들기 말인가요? 그런 것까지 필요할까 하는 생각이 들긴 하지만, 그래도 지원된다면 괜찮기는 하겠어요.
      마치, mkdir 명령이라든가 CreateDirectory 함수가 여러 서브디렉터리를 한꺼번에 만드는 걸 지원하지 않아서 불편한 것과 같은 맥락인 듯하네요.

  2. 김 기윤 2010/07/07 16:43 # M/D Reply Permalink

    namespace TH
    {
      namespace Gameplay
      {
        namespace CurtainFire
        {
          class Shooter
          {
            // 주저리주저리
          };
        }
      }
    }

    엄청난 들여쓰기 깊이..

  3. Angelra 2014/08/07 11:27 # M/D Reply Permalink

    안녕하세요~namespace를 찾다 좋은글 보고 감사드립니다.
    그런데 궁금한게 있는데, 알려주신걸 읽어보니 ADL(Argument dependent name lookup)기법이 기존의 오버로딩과의 차이점이 궁금한데 알려주실 수있나요. 두개의 차이점에 대한건 자료가 별로없네요 ㅠ

    1. 사무엘 2014/08/07 23:16 # M/D Permalink

      안녕하세요?
      보통 ADL이라고 하면
      우리 namespace를 명시해 주지 않아도 argument의 소속만 보고도 전역 operator 함수를 호출 가능하게 되는 게 장점이고,
      반대로 이 side effect 때문에 swap 함수 같은 건 std::라고 명시를 안 해 주면 namespace에 소속된 엉뚱한 게 호출되어 버릴 수도 있다..
      는 식의 예시가 제시되곤 합니다.
      http://stackoverflow.com/questions/8111677/what-is-argument-dependent-lookup-aka-adl-or-koenig-lookup
      이런 걸 말씀하시는 건가요? 도움이 되었으면 합니다. ^^

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/305

Comments List

  1. 주의사신 2010/06/28 16:29 # M/D Reply Permalink

    1. C++의 창조주 비야네 스트롭스트룹 할아버지는 typename보다는 class를 더 좋아하신답니다. 이유는 타자를 조금 덜 칠 수 있어서라네요.

    그리고 "반드시 class만 와야해"라는 의미에서 class를 쓰고, "모든 것이 다 올 수 있어"라는 의미에서 typename을 써 주는 개발자 분도 계십니다.

    Effective C++인가 More Effective C++인가 둘 중에 한 권에서 본듯합니다.

    2. 저는 typename 정확히 쓸 줄 몰라서 한 번은 void*로 위기를 넘겼지요....

    200줄도 안 되는 템플릿 코드 짜느라 3일 가까이 걸린 후로, 템플릿이 얼마나 어려운 놈인지 깨달았던 기억이 있습니다.

    1. 사무엘 2010/06/28 20:29 # M/D Permalink

      Effective C++ 시리즈는 정말 명저이고 저 역시 감명깊게 읽었습니다.
      (참고로 저는 class / typename 분리주의자.. ㅎㅎ 그리고 나중에 C++을 배운 사람들도 이제는 다 typename에 익숙할 겁니다.)

      typename 삽질은 저도 한 적이 있죠. 예전 블로그 글 찾아보시면 나옵니다. 아무리 생각해도 C++ 언어상 불가능할 리가 없는데 자꾸 컴파일 에러가 나서.. 엄청 고생했었네요.
      쓰는 우리 같은 사람도 힘든데, 저걸 컴파일러로 구현하기는 얼마나 더 힘들었을까요?

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/293

Trackbacks List

  1. ☆~의 생각

    Tracked from iruis' me2day 2011/01/18 15:25 Delete

    트위터 타임라인에서 보게 된 템플릿의 export 키워드. 닷넷같은 바이트코드가 아니면 구현이 안될 줄 알았는데 C++도 가능 하긴 하구나. 근데 이게 구현되면 컴파일러끼리 호환이 문제 될듯, 그

Comments List

  1. 주의사신 2010/06/12 09:46 # M/D Reply Permalink

    Visual Studio 쓰다가 export를 써 봤는데, 파란 색으로 표시해 주길래, 우와 드디어 VS가 export를 지원하는구나 생각을 하고 썼습니다. 컴파일 결과,

    "미래의 사용을 위해 예약 중"

    이라는 문구가 뜨더군요.(영문판을 쓰기 때문에 정확한 문구가 기억이 나지는 않습니다. 써 보면 되긴 합니다만...)

    그래서 다시 template을 원래대로 돌려놓았던 기억이 납니다.

  2. 김 기윤 2010/06/12 13:50 # M/D Reply Permalink

    Visual Studio 2010 에서 확인

    warning C4237: 'export' keyword is not yet supported, but reserved for future use

    주의사신님이 말씀하신 것과 같은 에러가 뜹니다 (..)

    문법적으로는 있으면 좋은데, 실제 구현이 정말 어려워서 죽어버린 키워드? (..)

  3. 사무엘 2010/06/12 16:18 # M/D Reply Permalink

    비주얼 C++ 2003은 export를 예약어로 전혀 인식하지 않으며(export 뭐임? 먹는 거임? 우걱우걱.. 컴파일러가 먹는 건 일반 심볼들.. ㅋㅋ),
    참고로 덧붙이자면 vector<list<int>> ap; 도 2003에서는 에러가 나는 반면, 2008은 정상 처리되네요. (닫는 꺽쇠를 > >로 띄워야 함)
    2005는 결과가 어떤지 모르겠습니다.

    말이 나왔으니 말인데, C++0x는 정말 기괴한 변화가 많아서 컴파일러 구현의 난해함은 한층 더 안드로메다로 갈 듯합니다.
    R-value 참조자 && 는 가히 억소리 나고
    특히 람다 대수 개념 도입 덕분에(함수도 자유롭게 value 취급) 이제 C++도 사실상 nested function 구현이 가능해진 거나 마찬가지로 보입니다.

    1. 김 기윤 2010/06/12 16:48 # M/D Permalink

      가히 '억' 소리 나는 C++0x 의 변화 ㄲㄲㄲ

      람다 함수는 정말 ...... 자유도는 상승했지만, 난이도도 덩달아..

  4. 김재주 2010/06/12 19:53 # M/D Reply Permalink

    이쯤되면 아예 C++의 호환을 포기해버리는 것도 한 방법이 아닐까 싶군요.

    사실 C도 C++도 너무 낡았죠?
    C#만 믿고 갑시다. M$님이 다 해주실 거야..

    1. 김 기윤 2010/06/12 21:35 # M/D Permalink

      그런데 기존에 C/C++ 으로 쌓아올린 것의 양이 너무 많다는 것이 문제입니다. C++ 의 호환을 포기하고 C# 만 믿고 가려면 기존의 라이브러리 등을 모두 포팅해야 하는데 과연...

      + 그리고 게임 등의 속도가 중요한 프로그래밍은 수행속도 등의 문제 때문에 C# 으로는 좀 곤란...합니다. 좀 더 하드웨어 성능이 올라가면 모르겠는데, 현재로써는...

  5. 김재주 2010/06/13 00:03 # M/D Reply Permalink

    김 기윤님께 //
    지금까지 쌓아둔 코드의 양이 많아서 다른 언어로 넘어갈 수 없다면, 50년이 지나도 100년이 지나도 계속 C,C++을 사용해야겠죠. 그건 아무리 생각해도 현명한 일은 아닙니다. 현대 컴퓨터 언어들이 그동안 이뤄낸 많은 발전들을 모두 포기해야 하니까요.

    그리고 게임 엔진 정도라면 C++로 구현하나 C#으로 구현하나 속도면에서 대단한 차이가 나지는 않습니다. 왜냐면 요즘 게임들은 많은 경우 CPU보다는 GPU의 연산량이 더 많은 경우가 많고, 컴퓨터들도 2개 이상의 CPU 코어를 가지고 병렬로 처리하기 때문이죠.

    오히려 Memory leak 등이 잘 발생하지 않기 때문에 C#이나 JAVA를 이용하는 편이 나은 부분도 있습니다. 이로 인한 오버헤드가 있긴 하지만 개발 편의 같은 부분에서 얻는 트레이드 오프 관점으로 생각하면 됩니다.


    다만 지금까지 만들어진 게임 엔진들이 C++을 기반으로 만들어져 있고, XBOX 360이나 PS3용으로 .NET 프레임워크가 포팅되지 않았기 때문에 사용하지 않고 있을 뿐입니다.

  6. 사무엘 2010/06/14 13:14 # M/D Reply Permalink

    김재주 님 C# 꽤 좋아하시는 듯.. ㅋ
    차라리 C++ 복잡 흉악함에 대한 대안은, 동급의 네이티브 코드 생성 언어인 D 언어가 될 수도 있겠다는 생각이 드네요.

  7. 김재주 2010/06/15 14:40 # M/D Reply Permalink

    C#처럼 실무에도 써 먹을만 하면서 파 보면 재밌는 언어도 흔치는 않죠.

    만약 네이티브 코드 생성이 반드시 필요하다면 D보다도.. 아예 새로 디자인하는 게 낫지 않을까 생각합니다.

  8. 김기윤 2011/01/18 15:26 # M/D Reply Permalink

    C#이라던가 Java 를 수업시간 등을 통해서 좀 익힌 뒤로 저의 관점이 좀 바뀌었습니다.

    많이 알아야 많이 보이는 듯 합니다...


    다만 왠지 C#이나 Java 는, (게임 프로그래머의 입장인 저한테는) 왠지 표현력이 적다...랄까, 답답하다는 느낌이 들었습니다. 단순히 익숙하지 않아서 그럴 수도 있겠지만, 왠지 의존적인 성향이 강하달까.. 그런 느낌이 듭니다. 아이러니한건, 의존성으로 따지면 객관적으로 볼때 C++ 이 최악이라는 건데도 이렇게 느낀다는 것...

    1. 사무엘 2011/01/18 21:14 # M/D Permalink

      무슨 뜻인지 알겠고 C/C++ 중독자-_-로서 저 역시 그 관점에 동의해요.
      C#과 자바의 생산성은 C/C++이 가히 엄두도 못 낼 수준인 건 사실입니다.
      허나 C/C++만이 지니고 있는 그 자유도와 포스는.. 소위 C/C++의 단점을 보완했다는 후대 언어들에서는 결코 찾을 수 없을 것입니다.
      참고로 저도 자바와 C# 중에서는 C#이 더 좋습니다. 그냥 괜히 느낌이요. ^^;;

Leave a comment

C/C++의 type string은 간단한 건 간단하지만 복잡한 건 한없이 복잡하다. C/C++ 프로그래밍 경력 10년이 넘는 본인조차 아직 그런 쪽에는 능숙하지 않으며, 좀 복잡한 type 선언을 해야 하면 옛날에 짜 놓은 코드를 복사해서 가져온다. -_-

복잡한 게 뭔지를 물으신다면, 이런 것을 말한다. 특정 함수의 포인터, 배열의 포인터를 되돌리는 함수의 포인터, 포인터의 참조자, C++ 멤버 포인터 등등... 생각만 해도 머리가 뱅뱅 돌지 않는지?

C/C++에서 뭔가 명칭을 선언하는 건 아래와 같이 일면 단순하다. 간단한 것, 상식적인 것부터 살펴보자.

type p;

이렇게 써 주면 p라는 명칭은 type이라는 타입으로 선언된다. p는 변수가 될 수도 있고 함수도 될 수도 있고 포인터나 배열 변수가 될 수도 있다. C++은 함수 내부의 아무 위치에서나 변수를 선언할 수 있으나, 함수 안에서 또 함수를 선언할 수는 없다. nested 함수라는 개념이 존재하지 않는 것이다.

type a, b, c;

처럼 콤마를 써서 여러 명칭을 동일 type으로 동시에 선언할 수도 있다.
type에는 int, float 같은 built-in type이 들어갈 수 있고, 사용자가 예전에 정의한 구조체· 공용체나 클래스가 들어갈 수도 있다.

C에서는 구조체· 공용체의 명칭 앞에 struct나 union 키워드를 생략할 수 없으며 생략하려면 typedef를 별도로 만들어야 하는 부조리가 있었으나, C++에서는 그런 한계가 없어졌다. type이 템플릿인 경우, 템플릿을 실제로 만들어 내는 argument도 < >에다 둘러싸서 넣어 줘야 하며, 타입 명칭이 다른 scope에 존재할 경우 :: 연산자도 써 줘야 한다. std::vector<int>처럼.

type 명칭에는 이 변수의 성격을 규정하는 modifier 키워드도 선택사항으로 들어갈 수 있다. 이런 예로는 const, volatile, register 같은 키워드가 있다.

type에 대한 설명은 여기까지로 하고, 그럼 p(명칭)에 대해 알아보자.
명칭은 한 번에 여러 개를 동시에 선언할 수 있고, 또 원한다면 p=1처럼 =을 써서 선언과 동시에 초기화도 가능하다. C++의 경우, 아예 ()을 써서 생성자 함수 호출을 바로 시키는 것도 가능하며 built-in type에 대해서도 생성자 함수 호출하듯 값을 초기화할 수 있다. 즉,

int *a=NULL, b=7; /* C style */
int *a(NULL), b(7); //C++ style

C에서는 위의 문장만 허용되는 반면 C++은 아래의 문장도 허용된다는 뜻이다.

자, 그럼 이제 진짜 복잡한 부분으로 들어가 보겠다.
C/C++의 문법이 판타지 같은 이유는, 분명 명칭의 type과 관련된 modifier들이 type 부분에 확실하게 구분되어 있는 게 아니라 name 부분으로 개별 적용되는 것도 있기 때문이다. 그렇기 때문에 C/C++은

int *a, b;

라고 선언하면 *라는 modifier는 a에만 적용되어 a만 int형에 대한 포인터가 되고 b는 일반 int가 되는 것이다. 그런데 D라는 언어는 그렇지 않아서 위와 같이 선언하면 a와 b의 타입이 모두 int*가 된다.

이런 식으로 개별적으로 적용되는 modifier로는 다음과 같은 것이 있다. 이런 것들이 막 섞이면 사람 머리 터지게 만든다. ^^;;

*p : p가 포인터임을 뜻한다. 변수의 왼쪽에 붙으며, 오른쪽에서 왼쪽으로 해석한다. *가 여러 개 붙으면 2중, 3중 포인터가 될 수 있다. (pointer to)
&p : C++에서 추가된 문법이며, p가 참조자임을 뜻한다. 쓰임이 포인터보다 훨씬 제한적이기 때문에 다중으로 붙을 수 없다. 용법은 *와 동일. (reference to)

int *&p;

라고 하면 우에서 좌로 & → * 순으로 해석되어 p는 포인터의 참조자가 된다(a reference to a pointer to integer). 반대로 참조자를 가리키는 포인터라든가 참조자를 또 가리키는 참조자라는 개념은 C++에 없기 때문에, &*나 && 같은 문법은 틀렸다. 포인터의 문법을 간소화하려고 만든 게 참조자인데 이는 상식적으로 당연한 얘기. 하지만 이중 포인터의 참조자인 **&은 있을 수 있다. 이 정도면 *와 &의 관계는 충분히 설명됐을 것이다.
다음,

p() : 어떤 명칭 바로 오른쪽에 ()가 붙었다면 이는 그 명칭이 함수임을 뜻한다. 쉽다.

p[n] : 그 명칭이 배열임을 뜻한다. 첨자가 들어있어야 하는 게 원칙이지만, 함수 argument라든가 일부 1차원적인 문맥에서는 첨자가 생략되어서 포인터와 별 차이 없는 용법이 되기도 한다. 영어로는 array of에 해당. []가 오른쪽 끝에 계속 붙으면 다차원 배열을 만들 수 있다.

그렇다면 명칭의 왼쪽에 포인터가, 오른쪽에 ()나 []가 다 붙어 있으면 어떻게 해석해야 할까?
일단 오른쪽 것부터 해석한다. 그 후 오른쪽 끝에 도달하면 왼쪽으로 간다. 그래서

int *a[10];

은 []이 먼저 해석되어 array of / pointer to / int가 되고, 따라서 ‘int *가 10개 있는 배열’이 된다.
이 순서를 바꾸기 위해서 또 괄호가 사용된다. 함수를 뜻하는 ()와는 쓰이는 문맥이 다르며, 의미도 다르다. 이걸 아는 게 중요하다.

int (*a)[10];

은 *이 먼저 해석된 후 오른쪽의 배열로 넘어가서 pointer to array[10] of int가 되고, 따라서 배열의 포인터가 된다. 사실, C/C++의 type string은 일종의 영어 어순을 따르고 있는 셈이다. 이걸 알면 쉽다. 꼭 기억하자.

int func(int x);
int (*funcptr)(int x) = func;

명칭 다음에 곧바로 ()가 나오면 함수 선언이 되나, 이름이 괄호로 둘러싸여서 *가 먼저 해석되므로 funcptr은 pointer to function, 즉 함수의 포인터가 되고, 자신과 prototype이 완전히 같은 func라는 함수를 가리킬 수 있게 되는 것이다.

닫는 괄호를 만나면 아직 해석되지 않았던 왼쪽으로 이동하고, 그러다가 여는 괄호를 만나면 다시 닫는 괄호 바깥의 오른쪽으로 가면서 완전히 바깥에 도달할 때까지 이 과정을 반복하면 된다.
따라서 명칭 뒤에 붙는 (), *, [] 같은 게 아무리 복잡하더라도, 명칭의 좌우에 가장 가까이 붙어 있는 놈이 뭔지만 보면, 얘가 포인터인지 함수인지 배열인지 정도는 바로 알 수 있다.

double ( *varr( double (*)[3] ) )[3];

위는 배열의 포인터와 함수의 포인터가 모두 동원된 예이다. 슬슬 머리가 아파질 것이다. varr의 좌우로 *와 ()가 있는데, 이때 오른쪽으로 먼저 간다. 그래서 varr은 함수가 되고 왼쪽의 *는 함수의 리턴값과 관계가 있게 된다. 그렇다. 이놈은 double 형 배열의 포인터를 인자로 받는 함수인데, 이 함수의 리턴값 역시 double 형 배열의 포인터라는 뜻이다.

double (* (*pfnFunc)( double (*)[3] ) )[3] = varr;

그리고 저 varr을 가리키는 함수의 포인터는.. varr만 (*pfnFunc)라고 또 감싸 주면 만들 수 있다. ^^;; 포인터를 되돌리는 함수의 포인터인 것이다.

int *(*(*fp1)(int))[10];

굉장히 변태-_-스러운 예제인데, 별표를 맨 왼쪽에 있는 것부터 [1], [2], [3]으로 번호를 매기자면,
fp1은 int 형을 인자로 받고, 원소 개수가 10인 int 포인터[1]의 배열에 대한 포인터[2]를 되돌리는 함수의 포인터[3]이다.

pointer to *
function (int)
returning pointer to *
array [10] of int*

이제 진짜 궁극의 변태 같은 예를 들면,

char *(*(**foo[2][8])())[10];

array [2][8] of
pointer to **
function ()
returning pointer to *
array [10] of char*

다시 말해 char*가 10개 들어있는 배열의 포인터를 되돌리는 함수의 2중 포인터를 담고 있는 2차원 배열이라는 소리이다. ^^;;

그럼 마지막으로, 또 하나의 기괴한 C++ 문법을 소개하면서 글을 맺겠다. 그것은 바로 멤버 포인터라는 특이한 포인터이다.

class CMyObject {
public:
 int x,y,z;
 void foo() {}
 void bar() {}
};

CMyObject obj;
int CMyObject::*pVal = &CMyObject::x;
void (CMyObject::*pFunc)() = &CMyObject::foo;

obj.*pVal = 10;
(obj.*pFunc)();

위의 코드에서 볼 수 있듯 pVal은 int형인 x, y, z중 한 멤버 변수를 가리킬 수 있고, pFunc는 자신과 prototype이 같은 foo()와 bar() 중 하나를 가리킬 수 있다.
일반적인 C++ 클래스의 non-static 멤버들은 멤버 포인터로 하여금 자신을 가리키게 할 때 "&클래스::멤버"와 같은 식으로 주소를 얻을 수 있다. 이때 어느 토큰 하나도 생략할 수 없다. 심지어 자기 클래스 멤버 함수 내부에서라도 자기 클래스 이름을 반드시 명시해야 한다.

멤버 포인터를 나타내는 ::*은 ::와 *가 합쳐진 것이다. 그러나 멤버 포인터를 실제로 사용하는 연산자인 .* 또는 ->* 는 완전히 한 토큰으로, 사이를 띄울 수 없다. 또한 멤버 포인터 함수를 선언하고 호출할 때는 반드시 괄호가 필요하다. 이걸 하지 않으면 오른쪽의 함수 호출 ()가 먼저 해석되어서 개체와 멤버 포인터가 먼저 연결되지 못하기 때문이라 한다.
마치 파스칼 언어에서 우선순위 처리의 특이점 때문에 (a=1) and (b>5)처럼 각 항을 괄호로 싸 줘야 하는 것과 비슷한 맥락이라 하겠다.

그나저나 C++은 :: . -> 이렇게 세 연산자가 모두 따로 존재하는 언어라는 게 특이하다. 자바나 C#은 . 하나가 이들 기능을 모두 수행한다.

Posted by 사무엘

2010/05/29 15:20 2010/05/29 15:20
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/279

Trackback URL : http://moogi.new21.org/tc/trackback/279

Comments List

  1. 김 기윤 2010/05/29 08:24 # M/D Reply Permalink

    저정도로 복잡한 포인터(....)를 쓸 일이 생길까요 과연;;

    1. 사무엘 2010/05/29 14:55 # M/D Permalink

      앗, 오늘 오후 3시로 예약해 놓은 글이 먼저 불쑥 올라가 버렸군요. O<-<
      저런 포인터는 뭐, 실용적인 가치야 거의 없다고 봐야죠. 배열 자체가 3차원 이상은 거의 쓸 일 없고, (*) 이런 게 나올 정도로 복잡한 타입이면 typedef가 필요합니다.

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/245

Comments List

  1. 땅콩맨 2010/04/14 17:54 # M/D Reply Permalink

    C++의 OOP개념은 대충 이해하고 넘어가서는 안되는 부분인것 같아요
    대충넘어가서는 안되기에 이해를 하려고 노력해야 하는 부분중의 하나
    라고 생각듭니다.

    내용 잘 읽었습니다.

  2. 김기윤 2010/04/14 19:32 # M/D Reply Permalink

    잘 읽었습니다.

    다중 상속은 쓰기도 힘들고 컴파일러로 구현하기도 힘들고, 문법도 복잡하고 (..)

    C++ 의 다중 상속 부분은 그런 점 때문에 다중 상속을 하는 방법만 스윽 한번 읽고나서 까먹었습니다 (..)

  3. Azurespace 2010/04/14 20:04 # M/D Reply Permalink

    C, C++에 비하면 C#은 정말 명쾌하고 깔끔하게 잘 설계한 언어라는 생각이 듭니다. 물론 저 두 언어에서의 시행착오가 있었고, 호환을 완전히 포기한 전혀 다른 언어이기에 가능하겠지만요.

  4. 사무엘 2010/04/15 01:08 # M/D Reply Permalink

    블로그질을 꾸준히 계속하다 보니, 이제는 글 올리고 나서 댓글 보는 재미도 차츰 쏠쏠해지는군요. ㅋㅋ
    다중 상속은 동일 기반 클래스의 중복 상속을 피하려다 보니 흉악하기 그지없는 판타지가 돼 버렸죠.
    하긴, <날개셋> 한글 입력기 소스 전체를 살펴봐도, 둘 이상의 클래스를 상속 받은 경우는 물론 있지만 다 ‘인터페이스’ 상속이기 때문에 가상 상속을 쓸 일은 전혀 없었습니다.

    참고로 자바, C#보다 좀더 네이티브스럽고 C/C++ 계열에 가까우면서 C++의 단점을 보완한 언어로 D도 있지요. static_if, static_assert 같은 예약어도 있고 굉장히 참신하고 기발한 기능이 많던 걸로 기억합니다.

Leave a comment
« Previous : 1 : ... 2 : 3 : 4 : 5 : 6 : 7 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/08   »
        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:
1236401
Today:
189
Yesterday:
525