1. 오버로딩과 오버라이딩의 관계

요렇게 Func라는 함수를 2개로 오버로딩한 A라는 C++ 클래스가 있다고 치자. 그리고 B는 A로부터 상속을 받았다.

class A {
public:
    virtual void Func(int x) {}
    void Func(int x, int y) {}
};

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

B *ptr = new B;

그렇다면 B는 일반적으로야 너무 당연하게도 A가 갖고 있던 Func라는 함수에 곧장 접근 가능하다. ptr->Func라고 치면 요즘 개발툴은 사용 가능한 함수 후보 2개를 자동으로 찾아서 제시까지 한다.

그런데 B가 Func 중 하나를 오버라이드 하면 사정이 달라진다.

class B: public A {
public:
    void Func(int x) {}
    (...)
};

이전에는 Func라는 이름은 전적으로 부모 클래스의 전유물로 간주되었지만, 오버로딩 형태 중 하나라도 파생 클래스가 오버라이드 한다면 이 이름은 부모의 것과 자식의 것을 구분해야 할 필요가 생기더라.
이제 ptr->Func를 하면 오로지 B에서 갖고 있는 것 하나만 제시된다. 이제는 ptr->Func(1, 5)를 한다고 해서 부모 클래스가 갖고 있는 인자 2개짜리 함수가 자동으로 정적 바인딩 되지 않는다. ptr->A::Func(1, 5)라고 써 줘야 된다.

본인은 이런 기초적인 동작을 보고도 내 직감과는 일치하지 않는 걸 보고 약간 놀랐다. 마치 함수의 리턴값만으로는 오버로딩이 되지 않는 것처럼 저것도 C++이 제공하는 유도리의 한계인가 싶다.
내 의도는 동일한 이름의 함수를 인자의 형태를 달리하여 가상 버전과 비가상 버전으로 둘 다 만들어 놓는 것이었다. 비가상 함수는 부모 클래스 한 곳에다가만 정의해 놓은 뒤, 받은 인자를 보정하여 가상 함수 버전을 호출해 주는 고정된 역할을 한다.

그런데 이름을 동일하게 해 놓으니 파생 클래스에서는 직통으로 호출할 수가 없어서 결국 이름을 다른 걸로 바꾸게 됐다. 이런 것도 마치 생성자나 소멸자에서 가상 함수를 호출하려 한 것과 비슷한 차원의 디자인 실수가 아닌가 싶다.

2. pointer-to-member의 우선순위

C++에서 pointer-to-member 연산자인 .* 내지 ->*는 데이터 멤버를 참조할 때는 별 문제될 게 없지만, 함수를 참조해서 호출할 때는 앞부분을 따로 괄호로 싸야 한다. 즉, (obj.*ptrfn)(a, b) 내지, (pObj->*ptrfn)(x, y)처럼 말이다.
괄호 없이 pObj->*ptrfn(x, y) 이런 식으로 바로 호출이 가능하면 더 깔끔하고 자연스러울 것 같은데, 문법이 왜 이렇게 만들어지게 됐을까?

표면적인 이유는 우선순위 때문이다. 일반적인 구조체 멤버 참조 연산자인 .와 -> 그리고 함수 호출을 나타내는 괄호 연산자는 모두 우선순위가 최상이며, 좌에서 우로 결합한다. 그렇기 때문에 a.b()는 토큰들이 아주 직관적으로 순서대로 해석된다.
그러나 pointer-to-member (이하 P2M) 연산자들은 곱셈 같은 이항 산술 연산자보다만 우선순위가 높을 뿐, 다른 단항 연산자나 괄호 연산자보다 우선순위가 낮다. 그렇게 자신만의 독자적인 우선순위가 있다.

이런 구조 하에서 괄호 없이 a->*b(x,y)라고 쓰면.. ->* 뒤의 b(x,y)가 먼저 해석된다. b는 뭔가 a에 적용 가능한 P2M을 되돌리는 함수가 되는 셈이다. 하지만 P2M 자체도 쓰임이 굉장히 드문 물건인데 하물며 P2M을 되돌리는 함수라..? 일상생활에서 좀체 볼 일이 없을 수밖에 없다. 그러니 저렇게 a->*b를 괄호로 싸지 않고 곧장 함수 호출을 하는 표현도 볼 일이 없어진다.

만약 P2M 연산자의 우선순위가 일반적인 . -> 의 순위와 대등하다면 a->*b(x,y)만으로 (a->*b)(x,y)의 효과를 낼 수 있다. 아까처럼 b 자체가 따로 P2M을 되돌리는 함수라면, 그쪽을 a->*(b(x,y)) 라고 괄호로 싸야 할 것이다.

그런데 b가 데이터가 아닌 함수 P2M을 되돌리는 함수이고, 리턴값으로 또 함수 호출을 해야 한다면 어떻게 될까?
저렇게 가상의 우선순위 체계에서는 a->*(b(x,y))(X,Y)와 같은 형태가 된다.
그러나 지금의 우선순위 체계로는 (a->*b(x,y))(X,Y)가 된다. 이렇게 비교를 하니 아무래도 b만 괄호로 싸는 것보다는 a까지 다같이 괄호로 싸는 형태가 그나마 더 자연스러워 보인다.

요컨대 . ->는 오른쪽 피연산자로는 끽해야 고정된 멤버 이름밖에 오지 못한다. 임의의 변수, 상수, 값이 올 수 없다. 성격이 ::와 비슷하며, 애초에 C++ 말고 오늘날의 다른 프로그래밍 언어들은 아예 . -> ::를 전부 구분 없이 . 하나로 간소화하는 게 트렌드일 정도이다. 오른쪽 피연산자 자체에 함수 호출이 있는지, 전체 결과값을 또 함수로 호출하는지 그런 걸 구분할 일은 없다.

그 반면, .* ->*는 생긴 건 단순 멤버 식별 연산자와 비슷하게 생겼어도 피연산자로는 사실상 아무 값이나 올 수 있다. 그렇기 때문에 뒷부분에 함수 호출 () 파트가 중구난방으로 나열되는 일을 막으려면 P2M은 . ->, 그리고 ()와 동일한 우선순위를 부여해서는 안 된다는 결론이 도출된다.

(a->*b)(x,y)에서 a와 b를 싸는 괄호에는 이런 사연이 숨어 있다. 그래서 얘들은 기존 연산자들보다 우선순위가 한 단계 낮아진 것이지 싶다. 클래스에서 함수 포인터를 되돌리는 operator 함수를 선언할 때 발생하는 다소 난감한 상황과 비슷하다. 저것도 결국은 정석적으로는 안 되고 typedef나 decltype의 도움을 받아야만 선언 가능하니 말이다.

파스칼은 비트 연산자가 논리 연산자의 역할까지 하고 있고 얘가 C/C++과는 반대로 산술 연산자보다 우선순위가 높다. 그렇기 때문에 if 문 안의 (A=B) and (C>5) 이런 항들을 일일이 전부 괄호로 싸야 해서 일면 불편하다. C++의 P2M 연산자의 우선순위는 마치 이런 사연을 보는 것 같기도 하다.

3. MFC와 C 라이브러리의 충돌

C/C++에는 빌드 과정에서 컴파일 에러뿐만 아니라, 현대의 언어에서는 찾기 힘든 개념인 링크 에러라는 게 있다.
이게 단순히 '요 명칭을(주로 함수) 선언만 해 놓고 정의를 안 했네요' 같은 간단한 것만 있으면 세상이 지금보다 훨씬 더 아름다워 보이겠지만, 현실은 그렇지 않다.

C/C++은 바이너리 인터페이스 수준에서 파편화가 매우 심한 걸로 악명높은 언어이다. C++ 함수 인자의 decoration은 말할 것도 없고, 당장 언어가 기본으로 제공하는 C/C++ 표준 라이브러리부터가 그러하다. 디버그/릴리즈, 32/64비트 같은 거야 섞일 일이 거의 없을 정도로 완전히 다른 configuration이니까 그렇다 치더라도 static/DLL, 컴파일러의 제조사와 제품 버전까지도.. 그냥 전부 제각기 따로 논다고 봐야 한다.

표준 라이브러리에 malloc, qsort 같은 영원불변의 간단한 물건만 있는 건 아니기 때문에 말이다. 그러니 다양한 출처에서 빌드된 라이브리러들을 한데 엮다 보면 별의별 링크 에러를 겪을 수 있다.
그래서 컴파일러를 Visual C++로 한정한다 하더라도, 대표적으로 MFC와 C 라이브러리(CRT)부터가 특정 상황에서 서로 부딪칠 수 있다.

MFC와 CRT는 구조적으로 둘 다 DLL 형태로 쓰거나 둘 다 static 링크하는 것만이 가능하다.
그런데 DLL 링크를 할 때는 괜찮은데 static 링크를 하다 보면 가끔 operator new / operator delete라는 메모리 할당/해제 함수가 MFC에도 들어있고 CRT에도 들어있다고.. 심벌 중복 정의라는 LNK2005 에러가 뜬다.

본인의 경우는 MFC를 사용하는 C++ 프로젝트에다가 precompiled header를 사용하지 않는 타 C 코드를 프로젝트에다 넣은 채로 MFC/CRT는 static 형태로 빌드를 시도했을 때 이런 상황에 놓이곤 했다.
operator new/delete 나부랭이야 내가 짠 코드도 아닌데 저 충돌 문제를 도대체 어떻게 해결하면 좋을까..?

이건 그래도 많이 알려지고 유명한 문제인지라 간단히 구글링만 하면 해결 방법이 수십 페이지씩 쭈루룩 뜬다.
/NODEFAULTLIB 옵션을 줘서 링커가 라이브러리들을 암시적으로 자동 공급하지 않게 하고, MFC의 static 링크용 라이브러리인 uafxcw.lib를 다른 라이브러리들보다 먼저 링크되게 하면 된다.

예전에 마소에서 제공했던 Windows 9x 유니코드 API 호환 layer인 unicows 라이브러리를 사용할 때도 링커 옵션을 비슷하게 특이하게 고쳐야 했던 걸로 기억한다. kernel32, user32 같은 통상적인 라이브러리보다 unicows가 먼저 공급 되어야 Windows API 호출이 훅킹 DLL로 갈 테니까 말이다.

아무튼 C/C++은 이런 디테일까지 신경 써야 하는 피곤한 언어이긴 하다. Visual C++의 차기 버전에서는 이런 문제는 자동으로 충돌을 감지하고 해결했으면 좋겠다.

4. 맥용 swscanf의 꼬장

표준 C 함수밖에 쓰지 않은 멀쩡한 x64용 C 코드가 Windows에서는 잘 돌아가던 것이 맥에서는 제대로 동작하지 않았다.
이 경우 원인은 대부분 사소한 곳에 있었다. 파일을 읽고 쓰는 곳에 long이 들어가 있는 게 대표적인 예다. Windows는 long도 int와 동급의 32비트로 보지만 맥에서는 이걸 64비트로 키웠기 때문이다. C/C++은 long도 그렇고 wchar_t도 그렇고.. 파편화가 너무 심하다..;;

단순히 기본 타입의 크기에 대해서는 본인이 예전에도 언급한 바 있다. 그것 말고 최근에는 또 다른 괴상한 사례를 발견했다.
long 문제와도 무관하고 도대체 안 돌아갈 이유가 전혀 없는 코드에서 오류가 발생하고 있었다. 어디에서부터 변수에 잘못된 값이 들어와 있는지를 추적해 보니 문제는 swscanf이었다. wchar_t 크기쯤이야 이미 감안하고 보정을 다 했기 때문에 문제될 여지가 없었다.

"설마 이게 문제이겠나" 싶었는데 설마가 사람을 잡았다. 읽어야 하는 문자열 뒤에 한글· 한자처럼 U+100 이후의 문자가 들어가 있으면 swscanf의 실행이 무조건 실패하고 있었다. 나는 "%X"라고 인자를 줬기 때문에  "FF00 가나다"이라는 문자열이 있으면 프로그램은 '가나다'는 전혀 신경쓸 필요 없고 0xFF00만 읽어 오면 된다. 게다가 'FF00'과 '가나다'의 사이에는 멀쩡히 공백까지 있어서 확인사살을 하고 있다.

그런데 확인을 해 보니 그냥 평범한 'ABC', '^&*%' 따위가 있을 때는 괜찮은데 '가나다'가 있을 때는 실패하더라. FF00의 값을 읽는 것과는 1도 아무 상관 없으며, Windows에서는 당연히 이런 현상 없이 값을 잘 얻어 온다.
이 때문에 swscanf를 쓰던 것을 wcstol로 바꿔서 %X의 역할을 대신하게 해야 했다. wide string 기반의 유니코드이니 무슨 로케일이나 인코딩 같은 설정을 할 필요도 전혀 없는데 swscanf가 왜 쓸데없이 꼬장을 부리는지, 더구나 맥만 왜 이러는지는 알 길이 없다. 살다 보니 별 일을 다 겪었다.

5. 정적 분석 써 본 소감

여느 프로그래머들과 마찬가지로 본인은 요즘 개발툴들이 제공하는 정적 분석 기능을 잘 사용하고 있다. 방대하고 복잡한 코드에 존재하리라고 꿈에도 생각을 못 했던 실수들이 걸려 나오는 경우가 많기 때문이다. 아주 특수한 상황에서 초기화되지 않은 변수가 사용될 가능성, 메모리 내지 리소스 leak이 발생할 가능성 같은 것 말이다.
역시 인간은 어쩔 수 없이 실수란 걸 늘 저지르는 동물이다. 기계가 이런 걸 안 잡아 줬으면 개발자들이 얼마나 고생하게 됐을까? 더구나 내가 직접 만들지도 않고 남이 짠 지저분한 코드를 인계받아서 유지 보수해야 하는 처지라면 말이다.

심지어 내가 머리에 총 맞기라도 했는지, 왜 코딩을 이 따구로 했었나 자괴감이 드는 오류도 있다.
물론 이런 것들은 처음에 코드를 그렇게 작성한 것은 아니다. 나중에 해당 코드가 변경되고 리팩터링이 됐는데 그게 모든 곳에 적용되지 않고 부분적으로 편파적으로만 적용되면서 일관성이 깨진 경우가 더 많다. 예를 들어 리턴값의 타입이 BOOL이다가 나중에 필요에 따라 int로 확장됐는데, 마치 Windows API의 GetMessage 함수처럼 체크 로직은 >0으로 바뀌지 않고 여전히 !=0이 쓰인다면 그런 부분이 잠재적으로 문제가 될 수 있다.

먼 옛날, Visual C++ 4~6 시절에는 프로그램을 빌드할 때 부가 옵션을 줘서 browse 정보를 추가로 생성할 수 있었다. 빌드 시간과 디스크 용량을 매번 추가로 투자해서 이걸 만들어 둬야만 임의의 심벌에 대해서 "선언/정의로 이동, 함수의 Calls to/Called by 그래프 조회" 같은 편의 기능을 사용할 수 있었다.

그랬는데 세월이 흘러서 지금은 C++ 같은 문맥 의존적인 언어조차 심벌 browse 기능 정도는 IDE의 백그라운드 컴파일러로 실시간으로 다 가능하고 최신 정보가 수시로 갱신되는 지경에 이르렀다. 그 대신 지금은 빌드 때의 추가적인 액세서리에 해당하는 것이 바로 '소스 정적 분석'이 된 거나 마찬가지이다. 단순히 기계어 코드를 생성하는 빌드보다 시간이 더 걸리는 대신, 통상적인 너무 뻔한 경고보다 훨씬 더 자세하고 꼼꼼하게 소스 코드에서 의심스러운 부분을 지적해 주는 것이다.

6. C++ 디버깅

하루는 회사에서 Visual C++ 2015로 개발하던 C++ 프로그램을 불가피한 사정 때문에 더 낮은 버전인 Visual C++ 2012로 빌드할 일이 있었다.
빌드는 별 문제 없이 됐지만, 그 프로그램은 제대로 실행되지 않고 초반부에서 바로 뻗어 버렸다.

디버거로 들여다보니 원인이야 어처구니 없는 실수 때문이었고 금방 밝혀졌다. 클래스의 생성자에서 멤버들이 ABC~XYZ 순으로 초기화되는데, A~C의 초기화 과정에서 아직 초기화되지 않은 뒤쪽 멤버들을 참조하는 멤버 함수를 호출했던 것이다.
컴파일러가 지역 변수 int를 초기화하지 않고 사용하는 것 정도는 곧장 지적해 주지만, 저런 실수까지 찾아내는 건 정적 분석의 경지로 가야 하는 모양이다.

그런데 문제는 이런 버그가 오랫동안 존재했던 프로그램이 지금까지 2015로 빌드할 때는 왜 잘만 돌아갔느냐는 것이다. 그것도 디버그가 아닌 릴리즈 빌드로 말이다. 이 객체는 new로 heap에다가 할당하는 것이어서 전역변수와는 달리 초기에 내부 메모리가 언제나 0초기화라는 보장도 없는데..
더구나 C++은 성능 덕후 언어이기 때문에 파생 클래스 부분까지 기본적인 초기화를 다 해 준 뒤에 기반 클래스의 생성자를 호출하는 것도 아니고, 생성자에서 자신의 초기화되지 않은 부분을 건드려서 순수 가상 함수 호출 같은 각종 문제가 얼마든지 발생할 수 있는데... 언어 디자인이라는 구조적인 차원에서 말이다.

프로그램의 빌드 configuration을 바꾸면 한 환경에서는 없던 문제가 금세 튀어나올 수 있다. 디버그에서 릴리즈, 혹은 반대로 릴리즈에서 디버그로 양방향이 모두 가능하다.
또한, 평소에는 탄탄한 최신 NT 계열 Windows에서 개발하다가 프로그램을 더 불안하고 연약한 환경인 9x에서 돌리면 숨겨진 버그나 리소스 누수가 튀어나올 수 있다. 요즘 컴파일러에서는 이렇게 할 수조차 없지만 말이다.

그런데 컴파일러의 버전을 더 낮췄더니 숨겨진 문제가 튀어나온 경험은 이번이 거의 처음이었다.

Posted by 사무엘

2017/11/29 08:32 2017/11/29 08:32
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1432

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

Leave a comment

컴퓨터 프로그램이 뻗는 방식을 분류하면 크게 다음과 같이 정리된다.

1. 아무 뒤끝 없이 그냥 뻗음(crash)

제일 단순하고 흔한 형태이다. 코딩을 잘못해서 잘못된 메모리에 접근하다가 튕긴 것이다. 그 예로는 null 포인터(null로부터 유도된 인근의 잘못된 주소 포함), 초기화되지 않은 포인터, 초기화되지 않은 배열 첨자 인덱스, 이미 해제된 메모리 포인터 등 참 다양하다.
혹은 애초에 메모리를 할당하는데 할당량에 엉뚱한 값이 들어와서 뻗은 것일 수도 있다. 가령, 음수만치 할당은 저 문맥에서는 대체로 부호 없는 정수로 바뀌면서 도저히 감당 불가능한 엄청난 양의 메모리 요청으로 바뀌기 때문이다.

2. CPU 사용 없는 무한루프

단독으로 돌아가는 프로그램이 제발로 이렇게 되는 경우는 잘 없다. 이건 스레드 내지 프로세스 간에 서로 아귀가 안 맞는 상호 대기로 인해 deadlock에 걸려서 마취에서 못 깨어난 상황이다. 그러니 엄밀히 말해 무한루프보다는 무한대기에 더 가깝겠다.
굳이 커널 오브젝트를 직접 취급하지 않고 윈도우 메시지를 주고받다가도 이렇게 될 수 있다. 가령, 스레드 A가 타 프로세스/스레드 소속의 윈도우 B에다가 SendMessage를 해서 응답을 기다리고 있는 중인데, B는 또 스레드 A가 생성한 윈도우에다가 SendMessage를 했을 때 말이다. 요 데드락을 해소하려고 ReplyMessage라는 함수가 있다.

3. CPU 쳐묵과 함께 무한루프

종료 조건을 잘못 명시하는 바람에 loop에서 빠져나오지 못하는 경우이다. 부호 없는 정수형으로 변수를 선언해 놓고는 while(a>=0) a--; 이런 식으로 코딩을 해서 무한루프에 빠지는 경우도 있다. 얘는 그래도 다행히 메모리 관련 문제는 없는 상황이다.

4. stack overflow와 함께 뻗음

이건 단순 뺑뺑이가 아니라 재귀호출을 종료하지 못하고 비정상적으로 반복하다 이 지경이 된 것으로, 컴에 메모리가 무한하다면 3번 같은 무한루프가 됐을 상황이다. 하지만 현실에서는 물리적인 자원의 한계가 있고, 또 컴이 취급 가능한 메모리 주소 자릿수 자체도 무한하지 않기 때문에 언젠가는 뻗을 수밖에 없다.

재귀호출도 반드시 A-A-A-A-A... 이렇게 단일 함수만 쌓이는 게 아니라 마치 유리수 순환소수처럼 여러 함수 호출이 주기적으로 쌓이는 경우도 있다.
스택은 다음에서 다룰 heap 메모리와는 달리, 그래도 그 정의상 할당의 역순으로 회수되고, 회수가 반드시 된다는 보장은 있다.

5. 메모리 쳐묵과 함께 뻗음

이건 heap memory의 leak을 견디다 못하고 프로그램이 뻗은 것이다. loop 안에서 계속해서 leak이 발생하면 꽤 골치아프다. 또한, 금방 발견되는 leak은 그나마 다행이지, 프로그램을 몇 주, 몇 달째 돌리다가 뒤늦게 발견되는 것은 더 답이 없고 잡기 어렵다. 프로그램이 뻗은 지점이 실제로 문제가 있는 지점과는 전혀 관계 없는 곳이기 때문이다. 뭔가 컴파일 에러와 링크 에러의 차이와도 비슷한 것 같다.

요약하면, 메모리 쪽 문제는 가능한 한 안 마주치는 게 낫고, 마주치더라도 프로그램이 곧장 뻗어 주는 게 디버깅에 유리하다. 1과 5는 포인터를 대놓고 취급하지 않는 C/C++ 이외의 언어에서는 프로그래머가 직접 볼 일이 드물다.
요즘은 그래도 디바이스 드라이버 급이 아닌 평범한 양민 프로그램이라면 메모리 문제로 뻗는 경우 전적으로 혼자만 뻗지, 컴퓨터 전체를 다운시키는 일은 없으니 세상 참 좋아졌다. 이게 다 가상 메모리와 보호 모드 덕분이다.

Posted by 사무엘

2017/10/03 19:34 2017/10/03 19:34
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1412

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

Leave a comment

C 언어는 가히 프로그래밍 언어계의 라틴어라 해도 과언이 아닌 대중적인 언어가 돼 있다. 얘는 알골(Algol), 그 다음으로 B라는 언어의 뒤를 이어 단순하게 C라고 명명되었으며 1972년에 만들어졌다. 이걸 보면 컴퓨터계에서 3.0 버전이 흥행 대박 친다는 법칙은 언어 분야에서도 유효한 것 같다.

C 언어의 고안자는 '데니스 리치'이다. 이 사람은 지난 2011년 가을에 마침 스티브 잡스와 거의 1주일 간격으로 나란히 작고했다(잡스가 먼저). 그래서 컴퓨터쟁이들 사이에서는 둘 중 덜 유명한 사람이 사실은 컴퓨터계에 훨씬 더 큰 공헌을 했다는 요지의 글을 올리곤 했다.

C는 기본적으로 컴파일 형태로 빌드되는 언어이며, 1990년대를 전후해서 16비트 도스 시절엔 볼랜드라는 회사에서 개발한 터보 C 컴파일러가 아주 대중적으로 쓰였다.
그러나 터보 C보다 이전에 IBM PC용으로 최초로 등장한 C 컴파일러는 Lattice C라고 다른 회사 제품이었다. 1982년인가 그렇다. 이게 그 먼 옛날에 타 플랫폼용으로 개발된 프로그램들을 도스용으로 포팅하는 데 중요한 선구자적 역할을 했다. 얘가 당대의 다른 후속 경쟁사 컴파일러들에 비해 코드 생성 성능도 좋았다고 한다.

사실은 Microsoft C도 Lattice C를 기반으로 개발되었다. 그러다가 1985년에 개발된 MS C 3.0부터 마소가 완전히 독자적인 컴파일러 개발 라인을 구축했다고 영문 위키백과를 보면 나온다. 브라우저에다 비유하자면 IE의 소스에서 모자이크의 소스를 완전히 떼어낸 것과 비슷한 격이겠다.

Windows의 경우 1.0은 처음에 파스칼로 개발되었으며, 이거 영향으로 실행 바이너리들을 들여다보면 export 심벌들의 명칭은 대소문자 구분이 없고 문자열도 앞에 길이가 기록된 형태로 저장되었다고 한다. 대소문자 구분이 없는 건 확실하게 본 기억이 있는 반면, 후자는 잘 모르겠다.

물론 초창기에도 파스칼이 아닌 C언어 기반의 Windows SDK가 있긴 했다. Windows 1.0 SDK의 경우 바로 저 초창기의 MS C 3.0까지는 아니고 4.0과 연계해서 동작했던 걸로 기억한다. 운영체제(?)의 개발과 컴파일러의 개발이 나름 병행되었던 셈이다. 그래도 뭐, 파스칼의 흔적이 어떤 형태로든 과거에 존재했기 때문에 PASCAL이라는 calling convention 명칭도 오늘날까지 legacy로 버젓이 전해지는 아닌가 싶다.

그러다 Lattice C는 1980년대 후반에 개발사가 타사에 인수되었으며 물건 역시 MS, Borland 같은 후발주자 대기업(?) 제품에 밀려서 역사 속으로 사라졌다. C를 제외하면 볼랜드는 파스칼을 민 반면, 마소는 빌 게이츠의 입김과 추억이 담긴 Basic을 밀었다. 베이직이 Quick-을 거쳤다가 나중에 폼 디자인 기능이 탑재된 Visual Basic이 되었다면, C 계열은 Quick-을 거쳤다가 C++ 언어에 MFC까지 탑재하여 Visual C++이라는 공룡으로 거듭났다. 물론, 그래도 VC에 지금과 같은 IDE의 프로토타입이라도 갖춰진 물건은 또 한참 뒤인 4.0 (1995)부터이다.

도스 시절에는 Turbo/Borland라는 브랜드로 볼랜드 컴파일러가 심지어 마소의 컴파일러조차도 따돌리며 리즈 시절을 구가했다. 1990년대 중반이 되면서 32비트 도스라는 틈새시장을 겨냥해서 Watcom, DJGPP 같은 제품이 꼽사리로 꼈을 뿐이며, 정작 마소와 볼랜드는 32비트 도스 플랫폼 지원은 상대적으로 미흡했다.

허나, Windows 95/NT가 널리 퍼지면서 주력 C/C++ 컴파일러는 Visual C++로 판도가 급격히 기울었다. Lotus 1-2-3이 하루아침에 급격히 밀리고 Excel이 천하를 평정했으며, 넷스케이프가 90년대 말에 정말 급격히 몰락한 뒤 IE 세상이 된 것처럼 말이다. 컴파일러는 브라우저처럼 무슨 끼워팔기 독점 같은 게 있지도 않았는데 어쩌다 상황이 바뀌었는지 모르겠다. (옛날엔 플랫폼 SDK와 함께 제공되던 공짜 컴파일러는 상용 Visual C++와 동급의 고성능 컴파일러가 아니었음)

자, 그럼 다음으로 C에 이어 C++도 언어와 컴파일러 역사를 회고해 보겠다. C++은 1970년대 말에 C with Classes라는 가칭으로 개발되었다가 1983년에 지금의 이름으로 첫 발표되었다. C++의 고안자는 덴마크 사람이다. 그리고 초기의 몇 년 동안(1980년대 중반) C++은 인지도가 안습했던 관계로 독자적인 컴파일러가 존재하지 않았다.

오늘날 C++의 위상과 지위를 생각하면 저런 시절이 존재했다는 게 믿어지지 않는다만, 그때는 C++ 코드를 C 코드로 변환해 주는 Cfront라는 전처리기 형태로 C++의 구현체가 명맥을 이었다. 말은 전처리기라고 했지만 소스 코드를 완전히 분석하고 변환하는 것이기 때문에 기술 수준은 엄연히 전처리기를 넘어 컴파일러의 front end급은 된다.

그러다가 C++ 직통 컴파일러가 등장한 것은 1980년대 말~1990년대 초이다. 메이저한 개발사인 볼랜드와 마소에서 C++ 컴파일러를 내놓은 것은 역시나 빨라도 1990년과 그 이후부터이지만, 1980년대 말에.. 그래픽 카드로 치면 VGA의 등장과 비슷한 시기에 C++ 직통 컴파일러를 내놓은 제조사도 있었다.
IBM PC/도스용으로는 Zortech C++가 그런 선구자 축에 든다. 딱 우리나라가 올림픽 하던 시절과 얼추 비슷하게 첫 작품이 나왔다.

Zortech C++은 훗날 1993년경에 Symantec C++ 이라고 브랜드 이름이 바뀌어서 6~7.x 버전까지 개발되었다. 도스와 OS/2, Windows (16/32비트)를 모두 지원하는데 역시나 볼랜드, 마소, 왓컴 같은 다른 브랜드에 밀려서 인지도는 그리 높지 못했던 듯하다.
본인은 먼 옛날에 어둠의 경로를 통해서 이 컴파일러 자체는 접한 적이 있다. Hello, world!만 출력하는 프로그램을 빌드해 봤는데 exe의 크기가 꽤 작게 나왔던 걸로 기억한다.

그리고 Zortech / Symantec C++ 컴파일러의 개발자는 Walter Bright이라고.. 프로그래밍 언어 연구와 컴파일러 개발에만 뼈를 묻은 유명한 아저씨이다. 원래 전공은 전산· 컴공도 아닌 기계공학인데 프로그래머로 전업 후, 컴공에서 최고로 어려운 분야 축에 드는 컴파일러를 곧장 파기 시작했다는 게 대단하다.
이 사람이 D 언어의 고안자이기도 하다는 걸 본인은 최근에 알게 됐다. D에 대해서는 개발자 개인이 아니라 Digital Mars라는 개발사의 이름만 알고 있었기 때문이다.

C++ 컴파일러를 개발하는 현업에 수십 년 종사했으니 그는 C++의 언어 구조와 빌드 과정에 존재하는 구조적인 비효율과 단점에 대해서 누구보다도 잘 알고 있을 것이다. 그러니 자신의 경험과 노하우를 집약해서 네이티브 코드 컴파일 언어이면서 C/C++의 단점을 보완한 새로운 언어를 직접 만드는 지경에 이르렀다. 하지만 D의 지지자· 사용자들이 어떻게든 똘똘 뭉쳐서 언어의 인지도를 끌어올리는 데 목숨을 걸어도 시원찮을 판에, 런타임 라이브러리가 Phobos와 Tango로 분열되고 커뮤니티가 폭파되는 큰 악재를 겪기도 한 모양이다.

거기에다 C++ 자체도 2010년대부터는 부스터를 단 듯이 언어와 라이브러리가 모두 하루가 다르게 미친 듯이 발전하는 중이다. 이게 과연 내가 알던 그 C++가 맞나 싶은 생각이 들 지경이며, 오죽했으면 같은 C++로도 이런 새로운 패러다임을 잔뜩 도입해서 코딩을 하는 걸 Modern C++이라는 비공식 명칭으로 따로 일컬을 정도이다. 이대로 가면 인클루드의 단점을 개선하는 import/패키지 기능까지 가까운 미래에 C++에 도입될 추세다. 그러니 "호환용 레거시가 너무 지저분하다"처럼 태생적으로 어쩔 수 없는 것 빼고는 단점들이 의외로 많이 해소되었다.

그걸로도 모자라서 다른 대기업이나 오픈소스 진영에서도 Rust처럼 네이티브 기반이면서 독특한 패러다임을 담고 있는 언어를 내놓고 있으니 D 역시 자신만의 메리트와 경쟁력을 확보하기 위해서는 갈 길이 아직 먼 것 같다.
C에서 파생형 언어 명칭을 만든 게 C++, C#뿐만 아니라 D라니 참 재미있다. C++뿐만 아니라 C#도 고안자가 덴마크 사람이라니 저 나라도 의외로 전산 강국인 듯하다.

(여담이지만 Walter Bright 아저씨는 컴파일러 개발자 겸 PL 연구자로 이름을 날리기 전인 1970년대부터 이미 Empire이라는 턴 기반 전략 시뮬 게임을 만들기도 했다. 워낙 너무 옛날이니 오늘날과 같은 컴퓨터에서 컬러 그래픽이 나오는 형태의 게임은 아니었겠지만, 아주 어린 시절부터 정말 비범한 분이었다는 건 확실해 보인다. 게다가 저 작품은 전략 시뮬 장르에서 맵의 전체 시야를 노출해 주지 않는 fog of war라는 개념을 첫 도입한 선구자이기도 하다고 한다.)

Walter Bright 말고, 또 볼랜드나 마소 계열도 아니면서 C++ 골수 덕후인 컴파일러 제조사가 하나 더 있다. 바로 Comeau. C++98이던가 03 시절에 그 악명 높은 템플릿 export 키워드를 유일하게 손수 다 구현한 이력도 있는 대단한 용자이다. 얘들 역시 1989년 초에 곧장 C++ 컴파일러를 내놓았으며, 그때부터 도스와 OS/2 등 다양한 플랫폼을 지원했는데, 거기 내부엔 또 어떤 출신과 배경을 가진 컴파일러/PL 괴수가 기업을 이끌고 있나 궁금해진다.

Comeau 컴파일러는 오늘날은 프런트 엔드로는 Edison Design Group의 제품을 사용하여 동작한다. 그럼 저 업체와는 어떤 관계인지 궁금하다. 그리고 프런트가 그런 관계이면 쟤들은 최적화와 타겟 코드 생성 같은 백 엔드 쪽에 차별화 요소가 있어야 할 텐데.. 백 엔드로는 아예 CPU 제조사라는 결정적인 텃새가 있는 인텔 컴파일러도 강세 아니던가? 그런 제품과 경쟁이 되려나 모르겠다.

이상. 이 글은 볼랜드나 마소 같은 유명 대기업 계열이 아니고 그렇다고 gcc 같은 오픈소스 진영도 아니면서 C/C++ 컴파일러를 상업용으로 제일 먼저 PC에다 구현했던 선구자들이 누군지를 문득 생각하면서 끄적여 보았다.

Posted by 사무엘

2017/03/24 19:25 2017/03/24 19:25
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1342

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

Leave a comment

1.
잘 알다시피 C언어는 원래 '이식성 있는 어셈블리'를 표방할 정도로 고수준 언어의 탈을 쓴 뭐랄까.. 안에서 돌아가는 모든 내부 과정이 있는 그대로 투명하게 노출되고, 프로그램이 메모리 내부에서 다루는 모든 물건들은 비트와 바이트 단위로 접근 가능한 식으로.. 모든 것을 프로그래머 재량에 맡기는 가볍고 이상야릇한 언어라는 성격이 강했다.

그래서 static/global을 제외하면 변수값의 초기화도 몽땅 수동으로 해야 하고, 배열 첨자 체크가 없고 심지어 문자열 타입도 없고.. 뭐 그랬다.
그 대신 공용체와 비트필드 같은 변태스러운 물건은.. C 말고 도대체 다른 어떤 언어에서 찾을 수 있겠는가? 단적인 예로, 부동소수점을 부호, 지수, 가수부별로 쪼개서 내부 구조가 어떻게 돌아가는지 보여주는 프로그램을 C 말고 다른 언어로 만드는 건 직관적이지 못하고 꽤 귀찮은 일이 될 것이다.

내가 직접 코드를 작성하지 않았는데 C가 언어 차원에서 자동으로 해 주는 일이라고는 환경변수 세팅이라든가 main 함수에 전달되는 argument의 파싱 같은 정말 최소한의 초기화밖에 없다시피했다.

하지만 C++은 언어 차원에서 몰래 하는 일이 더 있다. 우리가 빌드하는 프로그램에 코드가 추가되기 때문에 그 존재감과 오버헤드에 대해서 최소한의 인지는 하고 있어야 하는 것들이 종종 있다.
생성자와 소멸자, 임시 R-value 오브젝트, 암시적인 형변환 같은 건 그야말로 기본 중의 기본이고.. 가상 함수가 호출되는 원리도 아주 흔한 예다. C로 표현하자면 pData->vptr->pfnFuncXXX(pData, ...) 과 같은 급의 다단계 포인터 참조 오버헤드가 발생한다. 이런 건 C++ 한다는 사람이 아무리 초짜라도 절대로 몰라서는 안 된다.

가상 상속 정도면 가상 함수보다는 훨씬 볼 일이 없는 물건이다. 컴파일 타임 때 미리 계산된 오프셋으로 기반 클래스를 참조하는 게 아니라 기반 클래스의 위치 자체를 포인터를 통해 런타임 때 얻어 온다고 생각하면 된다.

더 어려운 걸로 내려가자면 pointer-to-member가 구현된 원리가 있는데, 이것도 forward 선언된 클래스 + 다중 상속이라는 변수를 만나면 내부 구현이 더럽게 복잡해지며 컴파일러간의 바이너리 호환성도 깨진다. C++에서 한번 홍역을 치른 뒤에 다른 언어에서는 별로 도입할 생각을 안 하고 있다.

global scope에 속한 객체들이 생성자와 소멸자가 호출되는 타이밍, 순서와 원리도 알아두면 좋다. 이식성을 위해서는 global 객체를 만들지 말고, 번거롭지만 차라리 포인터만 만들어 놓고 new와 delete를 프로그램이 수동으로 하는 게 권장되고 있다.

Exception이라는 것도 아주 요상한 물건이고...
끝으로, 언어 차원에서 지원되기 시작한 RTTI(런타임 시점에서의 타입 정보 인식) 기능도 있다. 하지만 이건 제대로 쓰이지는 않는 것 같다. dynamic_cast, typeid 같은 연산자 말이다. 가상 함수가 존재하는 모든 클래스들에는 자동으로 언어 차원에서의 타입 식별 정보가 추가된다.

얘는 구현 오버헤드가 만만찮으며, 언어의 기능에 의존하지 않고 자체적으로 RTTI를 구현한 레거시 코드도 많기 때문에 결국 컴파일러 옵션이 지정되었을 때만 지원되는 기능이 되었다. Visual C++의 경우 /GR 옵션이다.
개발 역사가 오래 됐고 다중 플랫폼을 지원하는 어지간한 프로젝트들은 이 기능을 사용하지 않는다. 마치 문자열 클래스만큼이나 파편화와 중복 구현이 난립해 있다.
사실, RTTI가 제대로 지원되려면 가상 함수가 존재하는 모든 오브젝트들이 공통으로 상속하는 베이스 클래스라는 개념도 있어서 그 베이스 클래스에서 타입 식별과 관련된 멤버들을 제공해야 하지 않나 싶다.

C++은 언어 차원에서의 개입을 최소화한다는 철학을 가진 언어에서 출발했는데 점점 기능이 비대해지고 언어 차원에서의 개입이 늘고 있다.

2.
포인터는 CPU가 메모리 위치를 식별할 때 사용하는 숫자로, 일반적으로는 machine word와 다를 바 없는 아주 가볍고(= 함수 인자로 값을 그대로 넘겨줄 수 있는) 단순한 자료형이다.
여느 자료형의 포인터는 정수와 reinterpret_cast로 형변환이 가능하다. 함수의 포인터는 + - 산술 연산이 되지 않지만 그래도 역시 정수와 교환이 된다.

하지만 포인터가 machine word 하나와 딱 대응하지 않을 때도 있다.
과거 16비트 시절에는 64KB보다 더 큰 영역의 메모리에 접근하기 위해 세그먼트 번호를 추가로 묶은 far pointer라는 게 있었으며 far은 예약어였다. 뭐 그래 봤자 이 포인터는 32비트 long 정수 하나에 대응했으니, Windows 프로그래밍에서는 L이라는 접두어로 원거리 포인터를 표현했다. LPSTR, LPVOID, LPCWSTR 등.

32/64비트로 오면서 그런 구분이 없어졌기 때문에 접두어 L은 불필요한 잉여가 되었다. 본인 역시 PSTR, PVOID, PCWSTR이라고만 쓴다.
단, PVOID는 winnt.h에 typedef로 정의돼 있는데 const void *는 왜 PCVOID라고 정의돼 있지 않고 여전히 LPCVOID만 있는지는 본인이 알 길이 없다. 믿어지지 않으면 한번 검색해 보시기 바란다. 정말 없다.

그리고 다음으로 machine word 하나와 딱 대응하지 않는 대표적인 기괴한 포인터는 아까도 잠깐 언급됐던 C++의 멤버 포인터이다. 다중 상속은 포인터간의 형변환이 일어났을 때 단순 언어적인 semantic뿐만 아니라 주소값 자체가 바뀔 수도 있는 상황을 만들었으며, pointer-to-member는 이를 보정하는 정보를 담느라 크기가 언제나 machine word 하나에 딱 들어가는 게 보장되지 않게 만들었다.

그래서 멤버 포인터는 신기하게도 reinterpret_cast나 C-style 캐스트로도 결코 숫자로 형변환이 되지 않는다. 숫자 하나가 아니라 구조체 같은 완전 생뚱맞은 자료형으로 취급된다. 크기와 내부 구현이 어떻게 가변적으로 달라질지 모르기 때문에 이것만은 C의 철학과는 정반대로 내부 구현과 접근을 프로그래머로부터 싹 감추고 숨겨 버렸다. 이거 굉장한 이질감이 느껴지지 않으신가?

자주 발생하는 일은 아니지만 구조체에서 어떤 멤버가 구조체의 시작 지점으로부터 정확하게 몇 바이트째 오프셋에 있는지 알고 싶을 때가 있다. 당연한 말이지만 이건 컴파일 타임 때 값이 결정되는 상수이다.
이럴 때 흔히 사용하는 방법은 &((STRUCTURE *)0)->member이다. 이렇게 해도 동작은 잘 하지만 그래도 더 깔끔한 방법이 있었으면 좋겠다는 생각이 든다.

개인적으로는 &STRUCTURE::member가 제일 직관적이고 깔끔한 형태라고 생각한다. 이건 pointer-to-member에 대입 가능한 멤버 주소를 얻을 때 사용하는 문법이다.
member가 static 데이터 멤버라면 저 값은 그놈 자신의 주소가 될 것이고, non-static이라면 메모리 주소가 아니라 자신의 오프셋이 된다. 비록 pointer-to-member(데이터 멤버)가 단순 오프셋의 superset으로서 그 이상의 추상적인 자료형이긴 하지만, 결국은 내부적으로도 오프셋을 갖고 있는 꼴이기 때문에 int형으로 reinterpret_cast도 됐으면 하는 생각이 든다. &((STRUCTURE *)0)->member을 안 써도 되게 말이다.

요즘 C++이 캡처가 없는 람다에 한해서 람다를 함수 포인터로 캐스트하는 것도 지원하듯이, 저것도 같은 맥락에서 정수형과 호환됐으면 하는 아쉬움이 남는다.

Posted by 사무엘

2016/11/22 08:33 2016/11/22 08:33
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1297

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

Comments List

  1. 비밀방문자 2016/11/24 12:03 # M/D Reply Permalink

    관리자만 볼 수 있는 댓글입니다.

    1. 사무엘 2016/11/24 13:27 # M/D Permalink

      즐코딩~~~ ㄱㅅㄱㅅ!

Leave a comment

1. C++의 new/delete 연산자

C++의 new와 delete 연산자에 대해서는 먼 옛날에 한번 글을 쓴 적이 있고,  연산자 오버로딩에 대해서 글을 쓸 때도 다룬 적이 있다.
new/delete 연산자는 메모리를 할당하고 해제하는 부분만 따로 떼어내서 operator new / opertor delete라는 함수를 내부적으로 호출하는 형태이며, 이건 클래스별로 오버로딩도 가능하다. 그리고 객체 하나에 대해서만 소멸자를 호출하는 일명 스칼라 new/delete와, 메모리 내부에 객체가 몇 개 있는지를 따로 관리하는 벡터 new[]/delete[]가 구분되어 있다는 점이 흥미롭다.

new는 메모리 할당이 실패할 경우 한 1990년대까지는 NULL을 되돌렸지만 요즘은 예외를 되돌리는 게 malloc과는 다른 점이라고 한다. 하긴, 요즘 세상에 메모리 할당 결과를 무슨 파일 열기처럼 일일이 NULL 체크하는 건 굉장히 남사스럽긴 하다.

1980년대의 완전 초창기, 한 터보 C++ 1.0 시절에는 벡터 delete의 경우, 원소 개수를 수동으로 써 주기까지 해야 했다고 한다. pt = new X[3] 다음에는 delete[3] pt처럼. 안 그래도 가비지 컬렉터도 없는데, 이건 너무 불편한 정도를 넘어 객체지향 언어의 기본적인 본분(?)조차 안 갖춰진 막장 행태로 여겨진지라 곧 시정됐다. 객체의 개수 정도는 언어 차원에서 메모리 내부에다 자동으로 관리하도록 말이다.

그런데 스칼라이건 벡터이건 메모리를 n바이트 할당하거나 해제하는 동작 자체는 서로 아무 차이가 없는데 operator new/delete와 operator new[]/delete[]가 따로 존재하는 이유는 난 여전히 잘 모르겠다.

new char[100]을 하면 operator new(100)이 호출되고, 생성자와 소멸자가 있는 new TwentyFour_byte_object[4]를 호출하면 x86 기준으로 24*4+4인 operator new[](100)이 호출된다.
operator new[]라고 해서 딱히 내가 할당해 준 메모리에 저장되는 객체의 개수나 크기를 알 수 있는 것도 아니다. 단지, new[]의 경우 내가 되돌려 준 메모리 바로 그 지점에 객체가 바로 저장되지는 않는다는 차이가 존재할 뿐이다. 맨 앞에는 오브젝트의 개수 4가 저장되기 때문.

즉 다시 말해 벡터 new[]는 operator new[]가 되돌린 포인터 값과, new operator[]를 호출한 호스트 쪽에서 받는 포인터 값에 미묘하게 차이가 생기며 서로 일치하지 않게 된다. 마치 다중 상속으로 인해서 this 포인터가 보정되는 것처럼 말이다.
그래도 스칼라/벡터 처리는 operator new/delete가 전혀 신경 쓸 필요가 없는 영역이며, 여전히 new/delete operator가 자동으로 하는 일일 뿐인데 그것 때문에 메모리 할당 계층 자체가 둘로 구분되어야 할 필요가 있는지는 여전히 개인적으로 의문이다.

그리고 하나 더.
operator new/delete는 오버로딩이 가능하다고 아까 얘기했었다.
global scope에서 오버로딩을 해서 오브젝트 전체의 메모리 할당 방식을 바꿀 수 있으며, new의 경우 추가적인 인자를 집어넣어서 placement new 같은 걸 만들 수도 있다. "메모리 할당에 대한 답은 정해져 있으니 너는 저 자리에다가 생성자만 호출해 주면 된다"처럼.. (근데 new와는 달리 delete는 왜 그게 가능하지 않은지 모르겠다만..)

global scope의 경우, Visual C++에서는 operator new/delete 하나만 오버로딩을 해도 new[], delete[] 같은 배열 선언까지도 메모리 할당과 해제는 저 new/delete 함수로 자동으로 넘어간다. 물론 new[]/delete[]까지 오버로딩을 하면 스칼라와 벡터의 메모리 요청 방식이 제각기 따로 놀게 된다.

그러나 클래스는 operator new/delete 하나만 오버로딩을 하면 그 개체의 배열에 대한 할당과 해제는 그 함수로 가지 않고 global 차원의 operator new[]/delete[]로 넘어간다.
이것도 표준에 규정된 동작 방식인지는 잘 모르겠다. 결정적으로 xcode에서는 global도 클래스일 때와 동일하게 동작하여 스칼라와 벡터 사이의 유도리가 동작하지 않았다.
메모리 할당이라는 기본적인 주제를 갖고도 C++은 내부 사연이 무척 복잡하다는 걸 알 수 있다.

2. trigraph

아래와 같은 코드는 보기와는 달리 컴파일되는 올바른 C/C++ 코드이다. 그리고 Foo()를 호출하면 화면에는 What| 이라는 문자열이 찍힌다.

void Foo()
??<
    printf( "What??!\n" );
??>

그 이유는 C/C++엔 trigraph라는 문자열 치환 규칙이 '일단' 표준으로 정의돼 있기 때문이다.
아스키 코드에서 Z 뒤에 나오는 4개의 글자 [ \ ] ^ 와, z 뒤에 나오는 4개의 글자 { | } ~, 그리고 #까지 총 9개의 글자는 ?? 로 시작하는 탈출문자를 통해 등가로 입력 가능하다.
이런 치환은 전처리기 차원에서 수행되는데, #define 매크로 치환과는 달리 일반 영역과 문자열 리터럴 안을 가리지 않고 무조건 수행된다. 그래서 문자열 리터럴 안에서 연속된 ?? 자체를 표현하려면 일부 ?를 \? 로 구분해 줘야 한다.

이런 게 들어간 이유엔 물론 까마득히 먼 역사적인 배경이 있다. 천공 카드던가 뭐던가, 저 문자를 한 글자 형태로 입력할 수 없는 프로그래밍 환경에서도 C언어를 지원하게 하기 위해서이다. 1950~70년대 컴퓨팅 환경을 겪은 적이 없는 본인 같은 사람으로서는 전혀 이해할 수 없는 환경이지만 말이다.
C(와 이거 호환성을 계승한 C++도)는 그만치 오래 된 옛날 레거시 언어인 것이다. 그리고 C는 그렇게도 암호 같은 기호 연산자들을 많이 제공하는 언어이지만 $ @처럼 전혀 사용하지 않는 문자도 여전히 있다.

오늘날 PC 기반 프로그래밍 환경에서 저런 trigraph는 전혀 필요 없어진 지 오래다. 그래서 Visual C++도 2008까지는 저걸 기본 지원했지만 2010부터는 '기본 지원하지는 않게' 바뀌었다. 이제 저 코드는 기본 옵션으로는 컴파일되지 않는다. /Zc:trigraphs 옵션을 추가로 지정해 줘야 한다.

C/C++ 코드를 가볍게 구문 분석해서 함수 블록 영역이나 변수 같은 걸 표시하는 IDE 엔진들은 대부분이 trigraph까지 고려해서 만들어지지는 않았다. 그러니 trigraph는 IDE가 사용하는 가벼운 컴파일러들을 교란시키고 혼동시킨다. 한편으로 이 테크닉은 소스 코드를 의도적으로 괴상하게 바꾸는 게 목표인 IOCCC 같은 데서는 오늘날까지 유용하게 쓰인다. 함수 선언을 void foo(a) int a; { } 이렇게 하는 게 옛날 원래의 K&R 스타일이었다고 하는데 그것만큼이나 trigraph도 옛날 유물이다.

차기 C/C++ 표준에서는 trigraph를 제거하자는 의견이 표준 위원회에서 제안되었다. 그런데 여기에 IBM이 적극적인 반대표를 던진 일화는 유명하다. 도대체 얼마나 케케묵은 옛날 코드들에 파묻혀 있으면 '지금은 곤란하다' 상태인지 궁금할 따름이다. 하지만 IBM 혼자서 대세를 거스르는 게 가능할지 역시 의문이다.

3. Visual C++ 2015의 CRT 리팩터링

도스 내지 16비트 시절에는 C/C++ 라이브러리를 DLL로 공유한다는 개념이 딱히 없었던 것 같다. 다음과 같은 이유에서다.

  • 도스의 경우, 근본적으로 DLL이나 덧실행 같은 걸 쉽게 운용할 수 있는 운영체제가 아니며,
  • 메모리 모델이 small부터 large, huge까지 다양하게 존재해서 코드를 한 기준으로 맞추기가 힘들고,
  • 옛날에는 C/C++ 라이브러리가 딱히 공유해야 할 정도로 덩치가 크지 않았음.
  • 예전 글에서 살펴 보았듯이, 16비트 Windows 시절엔 DLL이 각 프로세스마다 자신만의 고유한 기억장소를 갖고 있지도 않았음. 그러니 범시스템적인 DLL을 만드는 게 더욱 까다롭고 열악했다.

모든 프로세스들이 단일 주소 공간에서 돌아가긴 했겠지만, small/tiny 같은 64K 나부랭이 메모리 모델이 아닌 이상, sprintf 하나 호출을 위해서 코드/세그먼트 레지스터 값을 DLL 문맥으로 재설정을 해야 했을 것이고 그게 일종의 썽킹 오버헤드와 별 차이가 없었지 싶다. 마치 콜백 함수를 호출할 때처럼 말이다. 이러느니 그냥 해당 코드를 static link 하고 만다.

그 반면 32비트 운영체제인 Windows NT는 처음부터 CRT DLL을 갖춘 상태로 설계되었고, 그 개념이 Visual C++을 거쳐 Windows 9x에도 전래되었다. 1세대는 crtdll, msvcrt10/20/40이 난립하던 시절이고 2세대는 Visual C++ 4.2부터 6까지 사용되던 msvcrt, 그리고 3세대는 닷넷 이후로 msvcr71부터 msvcr120 (VC++ 2013)이다. 2005와 2008 (msvcr80과 90)은 잠시 매니페스트를 사용하기도 했으나 2010부터는 그 정책이 철회됐다.

그런데 매니페스트를 안 쓰다 보니 Visual C++의 버전이 올라갈 때마다 운영체제의 시스템 디렉터리는 온갖 msvcr??? DLL로 범람하는 폐단이 생겼고, 이에 대한 조치를 취해야 했다. C/C++ 라이브러리라는 게 생각보다 자주 바뀌면서 내부 바이너리 차원에서의 호환성이 종종 깨지곤 했다. 이런 변화는 함수 이름만 달랑 내놓으면 되는 C보다는 C++ 라이브러리 쪽이 더 심했다.

그 결과 Visual C++ 2015와 Windows 10에서는 앞으로 변할 일이 없는 인터페이스 부분과, 내부 바이너리 계층을 따로 분리하여 CRT DLL을 전면 리팩터링을 했다. 본인은 아직 이들 운영체제와 개발툴을 써 보지 않아서 자세한 건 모르겠는데 더 구체적인 내역을 살펴봐야겠다.

사실 C++ 라이브러리는 대부분의 인터페이스가 템플릿 형태이기 때문에 코드들이 전부 해당 바이너리에 static 링크된다. 하지만 그래도 모든 코드가 static인 건 아니다. 메모리 할당 내지 특정 타입에 대한 템플릿 specialization은 여전히 DLL 링크가 가능하다.
C++ 라이브러리가 어떤 식으로 DLL 링크되는지는 마치 함수 타입 decoration 방식만큼이나 그야말로 표준이 없고 구현체마다 제각각인 춘추전국시대의 영역이지 싶다.

4. Windows의 고해상도 DPI 관련 API

요즘이야 컴퓨터 화면의 해상도가 PC와 모바일을 가리지 않고 워낙 높아져서 프로그램의 UI 요소나 각종 아이콘, 그래픽도 크기 조절에 유연하게 대처 가능하게 만드는 게 필수 조건이 됐다. 폰트의 경우 저해상도에 최적화된 힌팅이 필요 없어질 거라는 전망까지 나온 지 오래다.
그러나 태초에 컴퓨터, 특히 IBM 호환 PC라는 건 텍스트 모드만 있다가 그래픽 모드라는 게 나중에 추가됐다. 그것도 그래픽 모드는 320*200이라는 막장급의 낮은 해상도에 4색짜리인 CGA에서 첫 시작을 했다.

시작은 심히 미약했다. 이런 저해상도 저성능 컴퓨터에서는 쑤제 도트 노가다로 최적화된 그래픽이나 비트맵 글꼴이 속도와 메모리 면에서 모두 우월했기 때문에 그게 세상을 평정했다.
그러나 컴퓨터 화면이 커지고 해상도가 크게 올라가면서 단순히 픽셀보다 더 고차원적인 단위를 도입할 필요가 생겼다. 물론 예나 지금이나 메뉴와 아이콘, 프로그램 제목 표시줄의 글자 크기는 제어판에서 간단히 고칠 수 있었지만 영향을 받는 건 오로지 그것뿐. 대화상자 같은 다른 요소들의 크기는 변하지 않았다.

그 고차원적인 단위를 일명 시스템 DPI라고 부른다.
평소에야 이 단위는 언제나 관례적으로 100%로 맞춰져 있었으며, 이게 125나 150% 같은 큰 값으로 맞춰져 있으면 응용 프로그램은 창이나 글자의 크기도 원칙적으로는 이에 비례해서 키워서 출력해야 한다.

대화상자는 픽셀이 아니라 내부적으로 DLU라는 추상적인 단위를 사용해서 컨트롤들을 배치하며 이 단위는 시스템 DPI를 이미 반영하여 산정된다. 하지만 CreateWindowEx를 써서 픽셀 단위로 컨트롤을 수동으로 생성하는 코드들이 이런 시스템 DPI를 고려하지 않고 동작한다면 프로그램의 외형이 많이 이상하게 찍히게 된다.

여기까지가 Windows 95부터 8까지 오랫동안 지속된 프로그래밍 트렌드이다. 시스템 DPI는 단순히 메뉴와 아이콘의 글자 크기와는 달리 운영체제 전체에 끼치는 여파가 매우 크다. 이건 값을 변경하려면 운영체제를 재시작하거나 최소한 모든 프로그램을 종료하고 현 사용자가 로그인을 다시 해야 했다.

시스템 DPI라는 개념 자체에 대한 대비가 안 된 프로그램도 널렸는데, 응용 프로그램들이 시스템 DPI의 실시간 변화에까지 대비하고 있기를 바라는 건 좀 무리였기 때문이다. 시스템 메트릭이 싹 바뀌기 때문에 이미 만들어져 있는 윈도우들이 다 재배치돼야 할 것이고 후유증이 너무 크다.

그런데 지난 Windows 8.1은 이 시스템 DPI에 대해서 또 어마어마한 손질을 가했다.
간단히 결론부터 말하자면 사용자가 재부팅 없이도 DPI를 막 변경할 수 있게 했다. 실행 중에 DPI가 변경되면 WM_DPICHANGED라는 새로운 메시지가 온다. 그리고 응용 프로그램은 자신이 실시간 DPI 변경에 대응 가능한지 여부를 운영체제에 별도의 API 내지 매니페스트 정보를 통해 지정 가능하게 했다.

DPI 변경에 대응 가능하지 않은 레거시 프로그램들은 시스템 DPI가 바뀌었는지 알지도 못하고 virtualize된 샌드박스 속에서 지낸다. DPI가 150%로 바뀌면서 사용자의 화면에 보이는 창 크기가 100에서 150으로 늘었지만, 응용 프로그램은 여전히 자신의 최대 크기가 100인줄로 안다. 그래서 100*100 크기로 그림을 찍으면 그건 운영체제에 의해 1.5배 비트맵 차원에서 크게 확대되어 출력된다.

그 프로그램은 처음부터 시스템이 150% DPI인 것을 알았으면 그에 맞춰 실행되었을 수도 있다. 그러나 실행 중의 DPI 변경까지 예상하지는 못하며, 그런 API가 도입되기 전에 개발되었기 때문에 운영체제가 그래픽 카드의 성능을 활용하여 그런 보정을 해 주는 것이다.
물론 이렇게 확대된 결과는 계단 현상만 뿌옇게 보정된 채 출력되기 때문에 화질이 좋지 못하다. 응용 프로그램이 고해상도 DPI 변화를 인식하여 직접 150*150으로 최적화된 그림을 다시 그리는 게 바람직하다.

그리고 시스템 DPI는 제어판 설정의 변경을 통해서만 바뀌는 게 아니다.
Windows 8.1부터는 모니터별로 시스템 DPI를 다르게 지정할 수 있다. 그래서 100%(96dpi)짜리 모니터에서 돌아가고 있던 프로그램 창을 125%(120dpi)짜리 커다란 모니터로 옮기면 거기서는 동일 프로그램이 그 DPI에 맞춰서 동작해야 한다. 물론 DPI가 바뀌었다는 메시지는 운영체제가 보내 준다.

이렇듯, 응용 프로그램은 처음에는 (1) 고해상도 DPI를 인식할 것만이 요구되었다가 나중에는 (2) 실행 중에 DPI가 변경되는 것에도 대비가 되어야 하는 것으로 요구 조건이 추가되었다.
옛날에는 시스템 전체의 화면 해상도나 색상수를 재부팅 없이 실시간으로 바꾸는 것도 보통일이 아니었는데 이제는 DPI의 변경도 그 범주에 속하게 되었다.

재부팅이 필요하다는 이유 때문에 그런지 Windows Vista는 전무후무하게 DPI의 변경에 마치 시스템의 시각 변경처럼 '관리자 권한' 딱지가 붙어 있기도 했는데 이것도 참 격세지감이다.

Posted by 사무엘

2016/06/02 08:32 2016/06/02 08:32
, , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1233

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

Comments List

  1. Dev 2016/06/03 12:07 # M/D Reply Permalink

    글 잘봤습니다.

    저도 1차원 배열에 대해 delete [] 이런식으로 해야하는지 의문을 가진적이 있어요
    char(1) 나 char(100)나 관리되는 방법에 차이가 없을거라 생각했었는데요ㅋ
    그치면 "1차원배열에 delete 만해도 된다" 라고 확인된바는 없다보니 정석으로 하고있지요 ㅎ
    연속되지 않은 공간에 배열을 할당 할 수도 있다고 하니 delete[] 를 반드시 써야한다고하는 글도 본적은 있습니다.

    오래전에 서진택님이 써놓은 자료를 보면 Borland C++은 DOS에서 new로 메모리 할당시
    할당한 메모리의 size까지 할당해서 앞에 기록하고 바로뒤의 포인터를 리턴한다고 본적이 있네요

    1. 사무엘 2016/06/03 13:22 # M/D Permalink

      delete operator의 형태야 스칼라형과 벡터형이 문법 차원에서 달라야 한다는 점에 저 역시 이의가 없답니다.
      소멸자 함수 호출하는 횟수 때문에 메모리 할당량뿐만 아니라 실제 원소 개수 정보도 필요해져서 저런 차이가 발생한 거지요. 앞부분에다 개수를 따로 저장하는 테크닉 정도는 도스용 Borland C++뿐만 아니라 요즘 컴파일러들도 동일하게 사용합니다.

      그에 반해 생성자 소멸자나 가상함수 따위가 없는 built-in type이야 스칼라/벡터 delete 뭘 쓰나 딱히 유의미한 차이가 없긴 합니다. C/C++은 강타입+정적 스코핑이기 때문에 그 정도 타입 판단은 컴파일 타임 때 가능하니까요.

Leave a comment

1.
코코아, Win32 API, MFC 같은 플랫폼 종속적인 API를 전혀 쓰지 않고 순수하게 표준 C/C++ 라이브러리 함수만으로 백 엔드 엔진만 만들었다 해도 Windows + Visual C++로 작성한 코드가 안드로이드 내지 맥 같은 다른 플랫폼에서는 곧장 컴파일 되지 않거나, 빌드된 프로그램이 의도한 대로 동작하지 않을 수 있다.

개인적으로는 회사에서 wchar_t 때문에 굉장한 불편을 겪었다. 잘 알다시피 Windows에서는 이게 2바이트이지만 다른 플랫폼에서는 4바이트이다. 플랫폼을 불문하고 2바이트 문자 단위로 동작하는 strcpy, strcat, strlen, printf, atoi 등등은 직접 구현이라도 해야 하는지..? 특히 파일로 읽고 쓰려면 말이다.

C++ string 클래스야 typedef std::basic_string<unsigned short> string16; 부터 먼저 만들어 놓고 썼다지만 그렇게 처음부터 객체를 만드는 게 아니라 raw memory를 다루는 상황에서는 해결책이 되지 못한다.

이런 게 원초적인 애로사항이고 또, 소스 코드 내부에서 유니코드 문자열 상수를 표현하는 방식도 또 다른 난관이다.
언어가 제공하는 L"" 문법은 wchar_t형 기반이다. 그러니 wchar_t 말고 명시적으로 unsigned short 배열에다가는 문자열 상수를 쓸 수 없고 "가"를 { 0xac00, }로 표현하는 식의 삽질을 해야 한다.

거기에다 비주얼 C++은 C++ 소스 코드나 명령 프롬프트가 UTF8과 전혀 친화적이지 않다는 다른 문제점도 있어 더욱 불편하다. 유니코드가 등장하면서 플랫폼별로 문자열을 다루는 방식이 너무 심하게 파편화됐다는 생각이 든다.
문자열을 저장하고 메모리를 관리하는 방식이 난립하는 것 말고(string class!) 문자열을 구성하는 문자를 표현하는 방식 그 자체부터가 말이다.

2.
하루는 Visual C++에서 표준 C 함수만 사용해서 만들어 준 코드를 안드로이드 내지 맥 OS 플랫폼으로 넘겨 줬더니 컴파일 에러가 났다. wcslen 함수가 선언되지 않았다고 꼬장을 부리는데 도무지 원인을 알 수 없었다. strlen은 인식되는데 wcslen은 왜 인식이 안 되는 거지?

그런데 알고 보니 wcslen은 strlen과는 달리 string.h가 아니라 wchar.h에 선언되어 있었다.
Visual C++은 string과 wchar에 wcslen을 모두 선언해 줬지만 타 플랫폼은 그렇지 않았다. 흐음~ 나의 불찰이다.

malloc/free 함수는 stdlib.h에도 있고 malloc.h에도 있다.
memset/memcpy는 string.h에도 있고 memory.h에도 있다.
그런 예가 몇 가지 있는 건 알고 있었지만 wcs* 함수는 Visual C++에만 string/wchar 겸용으로 선언돼 있었던 듯하다.
C 인클루드 헤더는 한 함수가 오로지 한 헤더에만 유일하게 존재하지는 않기도 하다는 것이 흥미롭게 느껴진다.

3.
요즘 표준 라이브러리들의 헤더 파일을 보면 함수의 인자마다 타입과 이름만 있는 게 아니라 소스 코드 정적 분석을 위한 Annotation 정보가 같이 들어있다. 같은 포인터라 해도 이건 읽기 전용, 쓰기 전용.. 쓴다면 어떤 조건으로 얼마만지 써지는지(옆의 인자만큼~) 같은 거.

그래서 함수 하나만 봐도 선언도 정말 덕지덕지 길어졌다. 이 정보들이 처음부터 있지는 않았을 텐데, 그 수많은 API들의 선언에다 일일이 다 기입하는 건 완전 중노동이었을 것 같다.
한때는 정적 분석 기능은 개발툴의 유료 최상급(엔터프라이즈 같은) 에디션에서나 접근 가능한 고급 기능이었는데, 이것도 죄다 무료로 풀리는 듯하다. 유료 GUI 툴킷이 통째로 MFC에 들어갔듯이 말이다.

4.
요즘은 CPU 아키텍처야 x86 아니면 ARM만 살아 남아서 그런지, 이식성을 논할 때 비트 순서, 일명 endian-ness 얘기는 별로 안 나오는 것 같다. 우리 주변에서 흔히 볼 수 있는 x86은 요지부동 리틀 엔디언인 반면, 옛날에 매킨토시의 밑천이던 PowerPC는 빅 엔디언이었다. 트루타입 폰트 포맷이 빅 엔디언 기반인 건 이런 애플의 영향력이 닿아서 그랬던 걸까?

먼 옛날 대학 시절에 터미널에 원격 접속해서 거기서 C 컴파일러를 돌려 봤던 게 본인으로서는 빅 엔디언 컴퓨터를 직접 구경한 처음이자 마지막 경험이다. 큰 자릿수가 앞부분부터 저장되다니 굉장히 신기했다. 이건 앞으로 수동 변속기 차량이라든가 IA64 (Itanium) 컴퓨터만큼이나 앞으로 또 접할 일이 없는 초희귀템으로 남을 것 같다.

최신 CPU인 ARM은 하드웨어 차원에서 endian-ness를 모두 지원하기 때문에 아무 쪽으로든 취사 선택이 가능하다고 한다. 사람으로 치면 완벽한 양손잡이이고, 철도에다 비유하자면 좌측/우측통행 전용 복선 철도가 아니라 어느 쪽으로든 운용 가능한 단선병렬과 비슷한 격이다.
결국은 다 지원하는 것으로 가는구나. 한글 코드에서 조합형/완성형 논쟁, CPU 미시구조에서 CISC/RISC 논쟁, 리눅스에서 그놈/KDE 셸 논쟁도 다 비슷한 방식으로 종결됐듯이 말이다.

비트 순서 같은 하드웨어 특성을 타는 요소 말고 소프트웨어 플랫폼과 언어 차원에서.. 사소하지만 코드의 이식성을 은근히 저해하는 요소는 내 경험상 몇 가지 있었다. 그러니 GUI가 없고 특정 운영체제의 API를 사용하지 않았다고 해서 무작정 이식이 잘 될 거라고 기대할 수는 없다.

5.
당장 떠오르는 건, 64비트 상수를 나타내는 % 문자가 파편화돼 있다(%I64d, %lld). 그리고 long이 Windows에서는 64비트 플랫폼에서도 여전히 32비트이지만 타 플랫폼은 그렇지 않다. 그러니 이식성을 생각한다면, long은 파일 오프셋 계산에 영향을 주는 곳에서는 절대로 구조체 멤버로 쓰이거나 sizeof의 대상이 돼서는 안 된다. (앞서 논했던 char_t도 마찬가지이고!) 그런 데서는 정말 닥치고 int32, uint64처럼 비트수를 명시한 typedef를 쓰는 게 안전하다.

C#이나 Java, D는 아무래도 1990년대 중후반에 PC에서 32비트 CPU 정도는 확실하게 정착한 뒤에 등장한 최신 언어이다 보니, 32/64비트 플랫폼을 불문하고 long이 처음부터 일관되게 64비트였다. 하지만 C/C++은 그보다 훨씬 전부터 컴퓨터 하드웨어의 발전의 격변기와 동고동락했던 언어이다 보니, 저런 깔끔함을 기대할 수는 없는 노릇이 돼 있다.

그리고, fopen에다 주는 옵션에서 r/w/a (+)만 있고 b/t 모드가 지정되지 않았을 때..
Windows는 binary 모드로 동작하는 반면 맥에서는(타 플랫폼은 확인 안 해 봄) 디폴트가 text였다. 멀쩡한 코드가 완전 엉뚱하게 동작하고 파일이 쓰라는 대로 써지지 않고 읽으라는 대로 읽히지 않아서 한창 문제를 추적했더니.. 결국은 이런 데에서 차이가 있었다. 이것도 표준 규격이 정의돼 있지 않나 보다.

말이 나왔으니 말인데, Visual C++은 fopen조차 쓰지 말고 fopen_s를 쓰라고 권한다. printf_s, qsort_s 같은 *_s 물건은 안전하고 편리하긴 하지만 언제까지나 이식 불가능한 Visual C++만의 전유물로 남을지 궁금하다..

strdup와 _wcsdup는 표준처럼 생겼지만 진짜 표준인지 아닌지 알쏭달쏭한 놈이다. 앞에 괜히 밑줄이 있는 게 아니다. _wtoi 이런 것도 Windows를 벗어나면 컴파일되지 않을 가능성이 높은 지뢰이니 strtol, wcstol을 쓰는 게 안전하다.
strtok의 경우 Visual C++은 토큰 컨텍스트를 따로 받는 _s 버전을 추가한 반면, 타 플랫폼은 strtok, wcstok 함수 자체가 그렇게 고쳐진 것도 있다. 이런 것들도 너무 골치아프다.

6.
끝으로, 이건 이식성하고는 큰 관계가 없는 얘기다만..
형변환 연산자인 static_cast는 코드 생성 차원에서 하는 일이 전혀 없거나(base class* → derived_class*, enum → int), 뻔한 값 보정(float → int, char → int), 또는 다중 상속일 때는 컴파일 타임 때 결정된 고정된 상수만치 this 포인터 보정 정도만(derived_class_B* → base_class) 하는 걸로 으레 생각했다.

그런데 다중 상속을 다룰 때 꼭 그런 일만 하는 건 아니다. 포인터가 처음부터 NULL이었다면, 거기서 또 얼마를 뺄 게 아니라 cast된 포인터도 그냥 NULL을 주는 예외 처리를 해야 한다. 과연 생각해 보니 그렇다. 아래 코드를 생각해 보자.

struct A { int a,b; };

struct B { int c,d; };

struct C: public A, public B { int e,f; };

void foo(B *pm) { printf("Received %p\n", pm); }

int main()
{
    C m, *pm=&m;
    printf("Passing %p\n", pm); foo(pm);
    pm=NULL; printf("Passing %p\n", pm); foo(pm);
    return 0;
}

단일 상속과는 달리, 다중 상속에서 passing의 값과 received의 값이 서로 달라질 수 있다고 아는 것은 하나를 아는 것이다.
그러나 NULL일 때는 다중 상속이더라도 언제나 NULL이 유지된다는 것이 함정이다. 우와.. 지금까지 한 번도 그런 경우를 생각한 적이 없었는데.. 꽤 충격적이다. 간단하지만 다중 상속의 보이지 않는 오버헤드를 보여주는 요소 중 하나이다.

Posted by 사무엘

2016/05/13 08:29 2016/05/13 08:29
, ,
Response
No Trackback , 5 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1226

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

Comments List

  1. Lyn 2016/05/14 00:08 # M/D Reply Permalink

    %lld 는 양쪽 다 먹으니 그쪽으로 통일하세용~

    그리고 정수형은 int8_t ~ uint64_t 로 이미 타입 def가 전부 통일 된 상태입니다.
    문자열도 string 도 이제 와선 u16string u32string 가 추가 되었구요. 나름 C++도 언어가 발전 하면서 플랫폼 차이가 점점 줄어 들고 있습니다...

    오히려 C쪽이 문제죠. VC++은 C를 지원하지 않으니... ICC는 쓰는곳이 그리 많지 않고,

    1. 사무엘 2016/05/14 00:30 # M/D Permalink

      아하. 제 기억으로 비주얼 C++이 아주 옛날 버전(4~6?)은 long long을 지원하지 않고 __int64만 지원했었습니다.
      그게 개선된 것처럼 %lld도 그렇게 된 듯하네요!

      u16string, u32string이 정식으로 들어간 건 이 글을 쓰고 예약 찍어 놓은 뒤 나중에 추가로 알게 됐답니다. 본문엔 업데이트가 안 됐군요. ㅎㅎ

    2. 3123Lyn 2016/05/15 16:40 # M/D Permalink

      네 맞습니다. long long 이 C++ 표준에 있기 전 나름대로 확장 했던 시절엔 그랫구요, 지금은 양쪽을 다 지원하고 있습니다.

      근데 개인적으론 long long 이 너무 길어서 __int64를 ...

  2. 사포 2016/05/16 08:59 # M/D Reply Permalink

    하긴 그래서 앵간한 C++ 라이브러리나 프레임워크는 자체적으로 문자열 타입을 만들고 API를 제공하는 경우가 많더라구요 ㅋㅋ 저도 최근에 XML 쪽으로 작업을 했었는데 초반에 인코딩과 관련해서 머리 좀 썩혔습니다..ㅠㅠ

    1. 사무엘 2016/05/16 10:08 # M/D Permalink

      그렇습니다. 이와 관련된 옛날 글은 http://moogi.new21.org/tc/743
      문자열은 연결 리스트나 배열과 더불어 중복 구현이 왕창 많은 기초 자료형일 거예요. 날개셋 한글 입력기도 자체 구현해서 씁니다. ^^

Leave a comment

C++ 클래스에서 타입이 char나 int 같은 정수형 스칼라(배열이 아닌)인 static const 멤버는 선언 후에 별도의 정의를 따로 안 해 줘도 된다. 선언과 동시에 값을 지정할 수 있다. 본인은 이에 대해서 3년 전에도 한번 글을 썼던 적이 있다.

class foo {
public:
    static const int bar = 500;
};

하지만 멤버가 char나 int 같은 간단한(primitive) 타입이 아니고 다른 구조체이거나, 혹은 간단한 타입이라도 배열이라면 얘기가 달라진다. 아까처럼 즉석 초기화를 할 수 없으며, 반드시 정의를 해 줘야 된다.

//헤더 파일
class foo {
public:
    static const POINT bar1;
    static const int bar2[];
};

//소스 파일
const foo::bar1 = { 1024, 768 }; // .x, .y
const foo::bar2[] = { 1, 2, 100 }; // [0], [1], [2]

자, 여기까지는 뭐 당연한 사실이다. 그런데 얼마 전엔 회사에서 이와 관련된 기괴한 일을 하나 겪어서 이곳에다 소개하고자 한다.
static const int 형태로 상수를 하나 추가해서 사용했는데 컴파일러가 이걸 도무지 인식을 못 하고 링크 에러를 내는 것이었다. global scope에 선언된 const 야 C++에서는 static이 디폴트이기 때문에 extern을 명시적으로 지정해야 한다지만, 멀쩡한 C++의 static const 멤버를 왜 인식을 못 하지?
게다가 더 이상한 건 Visual C++과 xcode는 다 문제 없는데 안드로이드의 빌드 환경인 gcc만 저런다는 것이었다.

멀쩡한 코드를 고치고 재빌드 하면서 잠시 헤매긴 했지만, 문제 자체는 구글링을 통해 원인을 찾아서 곧 해결할 수 있었다.
답부터 말하자면, 저 위의 bar는 선언과 초기값 지정이 되어 있음에도 불구하고 const int foo::bar1; 이라고 몸체도 소스 코드 어딘가에 정의해 줘야 했다. 단, 500이라고 초기값을 지정하는 건 선언부와 정의부 중 한 곳에다가만 하면 된다. 마치 함수의 디폴트 인자를 선언부와 정의부 중 한 곳에만 주면 되듯이.

소스 코드에서 어떤 숫자에다가 명칭을 부여하기 위해서는 enum이나 전처리기 #define을 사용할 수 있다. 이에 비해 const는 위의 두 방법과는 달리 명시적인 타입을 가지며, & 연산자를 이용해서 값의 주소도 얻을 수 있다는 차이가 있다.

static const 멤버를 R-value로만 쓰는 것은 그때 그때 그 숫자를 말 그대로 상수 형태로 집어넣는 것과 같다. 그러니 enum이나 #define과 별 차이가 없으며, 굳이 이 변수의 실체(= 주소)가 없어도 된다.
그러나 이 값을 그대로 파일에다 쓴다거나 할 때는 값이 담긴 메모리 주소를 줘야 한다. 그리고 C++의 템플릿 라이브러리 중에도 일단 value가 아니라 참조자가 전달되는 함수에다가 static const 멤버를 넘겨주면 그 멤버의 주소가 필요해진다.

그리고 그렇게 값이 아니라 주소가 필요한 경우가 있다면, gcc는 비록 선언부에서 값이 지정된 static const 멤버라 해도 별도의 몸체의 정의가 있어야만 링크를 제대로 수행해 줬다. 이니셜라이저가 있는 것도 아니고, 그냥 선언부에 있는 변수를 거의 그대로 다시 써 주는 잉여일 뿐인데도 그게 꼭 필요했다. 그 반면 Visual C++ 등 타 컴파일러는 몸체 정의가 있건 없건 결과는 동일하게 나왔다. 어째 이런 차이가 존재할 수 있는 걸까?

한편으로 gcc는, 주소만 요구하지 않는다면 구조체나 배열까지는 아니어도 float, double 같은 부동소수점 스칼라 상수를 몸체 정의 없이 static const로 선언하는 것도 허용해 줬다.

class foo {
public:
    static const double bar4 = 0.0025;
};

이거야말로 비표준이며 일단 Visual C++ 등 여타 컴파일러에서는 허용되지 않음에도 불구하고 gcc는 이를 지원한다. 혹시 나중에 표준으로 등재됐다면 댓글로 알려 주시기 바람. 요즘 C++은 하루가 멀다 하고 급변하고 있어서.. 과거 C++이 98과 03 버전을 거친 뒤 갑자기 1x대부터 확 바뀌기 시작했는데, 이는 마치 마소가 2000년대에 닷넷 때문에 C++ 지원을 등한시하다가 2010년대부터 트렌드가 바뀐 것과도 분위기가 일치하는 것 같다.

enum은 정수형 타입만 지원하기 때문에 실수 상수를 정의하는 건 #define 아니면 const밖에 선택의 여지가 없는데 gcc처럼 할 수 있다면 편리할 것 같다.
다만 &foo::bar4가 필요하다면 gcc라도 물론 const double foo::bar4; 라는 몸체 선언이 추가로 필요해진다.
아무튼, static const 멤버와 관련하여 gcc의 특이한 면모를 다시 생각할 수 있는 시간이었다.

Posted by 사무엘

2016/04/14 08:39 2016/04/14 08:39
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1214

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

Leave a comment

* 이 글의 내용은 클립보드 API, const 이야기의 연장선상에 있다. 관심 있으신 분은 예전에 썼던 글을 먼저 읽어 보시기 바란다.

1.
Windows API에는 GetPriorityClipboardFormat라는 함수가 있다. 이것은 클립보드로부터 '붙여넣기'(paste) 기능이 있는 프로그램이 사용할 만한 함수이다. 이 함수가 지목하는 포맷에 해당하는 클립보드 내용을 붙여넣으면 되며, 사실은 그보다도 먼저 이 함수의 실행 결과에 따라 '붙여넣기' 메뉴를 enable/disable시킬지를 결정하면 된다.

내 프로그램이 지원하는 클립보드 포맷이 단 하나밖에 없다면 IsClipboardFormatAvailable을 쓰면 된다. 그렇지 않고 여러 종류의 포맷을 지원하는데, 현재 클립보드에 존재하는 포맷 중에 우선순위가 가장 높은 놈은 무엇인지 알고 싶다면 말 그대로 저 priority라는 단어가 들어간 함수를 써서 요청을 해야 한다. 이것은 마치 커널 오브젝트의 동기화에서 쓰이는 WaitForSingleObject와 WaitForMultipleObjects 함수쌍의 관계와 일면 비슷하다.

그런데 내가 궁금한 점은 이 함수의 첫째 인자가 const UINT *가 아니라 왜 그냥 UINT *냐는 것이다.
첫째 인자는 함수로 전달하는 복수 개의 클립보드 포맷 리스트의 포인터이며, 속성이 엄연히 out이 아닌 in이라고 명시되어 있다. 저 함수 역시 인자로 받은 리스트 내용을 참조만 할 것이니, 포인터가 read-only가 아니라 일반 read-write 형태여야 할 하등의 이유가 없다. WaitForMultipleObjects만 해도 HANDLE의 배열 포인터는 분명히 const HANDLE *로 잡혀 있지 않던가?

저게 const라면,

static const table[] = { CF_UNICODETEXT, CF_HDROP }; //사람이 텍스트로 입력한 파일명 & 탐색기에서 복사해 놓은 파일명을 모두 받아들임
int res = GetPriorityClipboardFormat(table, ARRAYSIZE(table));

이런 식으로 간편하게 static const 배열을 함수에다 넘겨 줄 수 있을 텐데, const가 아니기 때문에 일이 좀 귀찮아진다. table에 const 속성을 제거하든지, 아니면 함수에다 넘겨 줄 때 const_cast 연산자를 써 줘야 한다. 내 경험상, 강제로 const 포인터로 고친다고 해서 딱히 프로그램 동작에 문제가 생긴다거나 하는 건 아니더라.

하지만 내 프로그램 내부에서 값을 고칠 일이 절대로 없기 때문에 const로 엄연히 봉인해 버리고 싶은 테이블을 봉인하지 못한다니, 썩 보기 좋아 보이지 않는다. 또한 const_cast 같은 연산자가 늘어나는 건 내 소스 코드에서 무질서도가 쓸데없이 증가하는 듯한 느낌을 준다.

물론, 동일한 유형의 데이터에 대해서 클립보드 포맷을 복수 개 지원한다는 건 대개가 custom 포맷과 표준 포맷을 혼용하는 경우이다. 리스트의 모든 원소가 상수가 아니며, 우선적으로 선택되었으면 하는 앞부분은 어차피 RegisterClipboardFormat의 리턴값인 경우가 많다. 워드 프로세서라면 모든 정보가 보관되어 있는 고유 포맷일 것이고, 텍스트 에디터라면 칼럼 블록을 명시한 고유 포맷이다.

그러니 저 함수의 인자로 전달되는 배열 포인터는 애초부터 const가 아니라 일반 배열일 가능성이 더 높다. 메시지만 해도, 시스템 차원에서의 유일성을 보장받기 위해 RegisterWindowMessage로 특수하게 등록한 메시지는 WM_USER+x와는 달리 가변적인 값이다. 그렇기 때문에 윈도우 프로시저에서 switch/case를 이용해 걸러낼 수가 없으며 if문을 써서 비교해야 하지 않던가. (이 두 Register* 함수는 프로토타입이 UINT Foo(PCTSTR name)로 완전히 동일하며, 명칭을 hash하여 숫자로 바꿔 준다는 공통점이 있어서 더욱 동질감이 느껴진다)

그런데.. 그런 식으로 치자면 WaitForMultipleObjects가 받아들이는 HANDLE도 NULL이나 INVALID_HANDLE_VALUE 같은 오류 상황 말고는 고정불변 상수값이 존재할 여지가 전혀 없는 타입이다. 값의 가변/불변성과는 무관하게 해당 함수가 값들을 읽기만 한다면 함수 인자가 응당 const 포인터로 잡혀 있어야 할 텐데, 클립보드의 저 함수는 왜 저렇게 설계되었으며 디자인에 문제를 제기하는 사람이 왜 없는지 궁금하다.

2.
서식이 없고 칼럼 블록도 없는 일반 텍스트 에디터에서 붙이기를 구현한다면 굳이 custom 포맷을 등록할 필요는 없을 것이고 애초에 복수 개의 클립보드 포맷을 살펴볼 필요도 없을 것이다. 단, 여기에도 원래는 유니코드/ANSI와 관련된 미묘한 문제가 있다. wide 문자 기반인 CF_UNICODETEXT와, ansi 문자 기반인 CF_TEXT가 따로 존재하기 때문이다.

Windows NT는 이들 사이에서 '유도리'를 제공한다.
응용 프로그램이 유니코드 텍스트 형태(CF_UNICODETEXT)로 클립보드에다 텍스트를 복사해 놓은 뒤, 나중에 유니코드를 지원하지 않는 프로그램이 클립보드로부터 CF_TEXT 포맷을 요청하는 경우 운영체제는 CF_TEXT도 있다고 답변을 하며, CF_UNICODETEXT로부터 CF_TEXT 데이터도 자동으로 만들어 준다. 그 반대도 물론 해 준다. (복사는 CF_TEXT로, 붙여넣기는 CF_UNICODETEXT로)

마치 Get/SetWindowText, WM_GET/SETTEXT에서 운영체제가 A/W 문자열 보정을 하듯이, 클립보드에 대해서도 그런 보정을 해 준다. 재미있지 않은가? 그러니 오늘날의 Windows에서는 유니코드를 지원하는 프로그램은 어떤 경우든 CF_UNICODETEXT만 생각하고 요걸로만 읽고 쓰면 유니코드를 지원하지 않는 구시대 프로그램과도 의사소통에 아무 문제가 없다.

하지만 문제가 되는 건 내부적으로 유니코드를 사용하는 프로그램이 Windows 9x까지 고려해서 만들어질 때이다. 9x 계열에서는 CF_UNICODETEXT는 응용 프로그램이 굳이 요청해서 자기들끼리 쓰지 않는 한, 운영체제 차원에서 공식적으로는 '없는 포맷', 지원하지 않는 유령/공기 포맷이다. 고로 NT 계열과 같은 타입 자동 변환이 없다.

텍스트 에디터가 자기는 내부적으로 유니코드 기반이라고 해서 자신이 native로 사용하는 CF_UNICODETEXT 형태로만 데이터를 복사해 놓으면, 기존 메모장 같은 프로그램에서는 텍스트 붙이기를 할 수 없게 된다.
그렇기 때문에 9x 계열과 유니코드라는 두 토끼를 모두 잡으려면 응용 프로그램은 클립보드로부터 데이터를 읽을 때도 IsClipboardFormatAvailable가 아니라 저 GetPriorityClipboardFormat를 써서 CF_UNICODETEXT와 CF_TEXT를 모두 살펴봐야 한다. 물론 1순위는 유니코드로 하고 말이다.

그리고 빈 클립보드에다 텍스트를 복사해 넣을 때에도 CF_UNICODETEXT부터 먼저 집어넣은 뒤, CF_TEXT 타입도 자동으로 생겨 있는지를 IsClipboardFormatAvailable로 체크하고, 없으면 ansi 텍스트도 수동으로 넣어 줘야 한다. Windows NT에서는 유니코드 텍스트만 집어넣어도 CF_TEXT로 붙여넣는 게 저절로 가능하지만 9x에서는 그렇지 않기 때문이다.

참 얼마 만에 클립보드 얘기를 또 꺼내게 됐는지 모르겠다. 지금이야 먼 과거의 유물이 됐다만, Windows 9x 시절에는 텍스트 하나를 유니코드 형태로 주고받을 때는 이렇게 미묘하게 귀찮은 면모가 좀 있었다.

클립보드에다 데이터를 지정하는 SetClipboardData는 GMEM_MOVEABLE을 지정해서 GlobalAlloc으로 평범하게 할당한 메모리 핸들을 넘겨 주는 방식으로 사용하면 된다. 함수를 호출한 뒤부터는 그 메모리 핸들은 운영체제의 관할로 넘어가기 때문에 우리가 임의로 해제해서는 안 된다.
이것과 굉장히 비슷한 개념으로 운용되는 다른 함수는 SetWindowRgn인 것 같다. 평범한 HRGN을 넘겨 주지만 이제부터 이 region은 운영체제의 관할이 되기 때문이다. 우리가 더 건드려서는 안 된다.

3.
C/C++은 여느 언어와 마찬가지로 const라는 속성이 존재하는데, 이것은 (1) 완전히 붙박이 상수가 될 수 있으며, 아니면 (2) 런타임 때 가변적인 초기값이 붙는 것도 가능하지만, 한번 값이 정해진 뒤에는 불변이라는 의미도 될 수 있다.
이거 혼동하기 쉬운 개념이다. C/C++의 const는 (1)은 딱히 보장하지 않고 일단은 (2)만 보장한다.

const형 변수를 초기화하는 초기값으로는 반드시 case 레이블이나 static 배열의 크기처럼 컴파일 타임 때 값이 결정되는 상수만 와야 하는 게 아니다. 변수도 얼마든지 초기값으로 지정할 수 있으며, 이건 그냥 const뿐만 아니라 static const에 대해서도 마찬가지이다. 애초에 const는 타입에 제약이 없으니까. (1)을 정수 명칭에 한해서 보장해 주는 건 enum, 아니면 템플릿 인자이다.

그래서 C#은 이 두 개념을 const와 readonly로 확실하게 구분해 놓았는데, 이건 상당히 매력적인 조치라 여겨진다. 배열· 테이블도 const로 지정이 가능하니까 말이다. 또한 붙박이 불변 const는 스택에든 어디든지 여러 인스턴스가 중복해서 존재할 이유가 하등 없으니 static 속성도 자동으로 포함하는 걸로 간주된다.

한편, 포인터가 const라면(가령 p) p 자신의 값을 바꿀 수 없는지, 아니면 p가 가리키는 메모리의 값을 바꿀 수 없는지(*p 혹은 p->)가 문제가 된다. const TYPE *p라고 하면 *p가 잠기고, TYPE * const p라고 하면 p가 잠긴다. const TYPE * const p라고 하면 짐작하시겠지만 둘 다 잠긴다.
문법이 좀 복잡한데, const T와 T const는 의미상 동일하다. 그리고 포인터값 자체를 잠글 때는 * 뒤에다가 const를 추가로 붙인다고 생각하면 비교적 직관적으로 이해할 수 있다.

하지만 포인터라는 게 지니는 값은 NULL 같은 특수한 경우가 아니고서야 언제나 가변적인 메모리 주소이기 때문에.. 포인터에다 const는 p 자신이 아닌 *p를 잠그는 용도로 훨씬 더 많이 쓰인다. 클래스 멤버 함수에 붙는 const도 this 포인터에 대해서 *this를 읽기 전용으로 잠그는 역할을 한다. this는 예약어이다 보니 this의 주소값 자체를 변경하는 건 어떤 경우든 원래부터 가능하지 않으니 말이다.

그리고 *p가 잠긴 const 포인터에 대해서 그 잠김을 일시적으로 해제하는 형변환 연산자는 잘 알다시피 const_cast이다. 다만, const가 사라진 TYPE *라는 타입명을 그 뒤의 <> 안에다가 수동으로 일일이 써 줘야 한다. 이름은 const_cast이지만 얘는 '쓰기 금지'뿐만 아니라 언어 차원에서의 타입과 "무관하게" 변수 접근 방식과 관련하여 붙어 있는 추가적인 제약 속성을 제거할 때에도 쓰인다. volatile이 대표적인 예이고, 비주얼 C++의 경우 IA64 아키텍처 기준으로 __unaligned 속성도 이걸로 제거할 수 있다. (IA64는 기본적으로 machine word 단위로 align이 된 곳만 메모리 접근이 가능한지라..)

Windows API에는 포인터의 const-ness 구분 때문에 불편한 게 몇 군데 있다. 대표적인 건 CreateProcess 함수.
실행할 프로세스를 가리키는 명령줄은 PCTSTR이 아니라 PTSTR이다. 함수가 내부적으로 실행될 때는 파일명과 인자명을 구분하기 위해서 문자열의 중간에 \0을 삽입해서 토큰화를 하기 때문이다. 즉, strtok 함수가 동작하는 것처럼 토큰화를 하며 문자열을 변경한다.

이 함수는 실행 후에 문자열 내용을 다시 원래대로 되돌려 놓긴 하지만, 명령줄 문자열을 나타내는 메모리는 일단 read-only가 아니라 쓰기가 가능한 놈이어야 한다. *a+=0; 을 수행하려면 비록 *a의 값이 실제로 변경되지는 않지만 그래도 가리키는 메모리가 변경 가능해야 하는 것과 비슷한 이치이다.
Windows NT 계열에서 CreateProcessA 함수를 호출하면 얘는 어차피 문자열을 쓰기 가능한 문자열에다가 유니코드로 변환해서 내부적으로 W 함수가 사용된다. 그렇기 때문에 A함수에다가 (PSTR)"notepad.exe sample.txt" 이런 식으로 줘도 안전하긴 하다. 그러나 범용성을 고려하면 이는 그렇게 깔끔한 코드라고 보기 어렵다.

다음으로 DrawText도 기능을 제공하는 형태가 조금 지저분하다.
얘는 문자열을 받아들여서 출력하는데 폭이 부족할 경우 문자열을 있는 그대로 출력하는 게 아니라 "Abc..."라고 줄여서 출력하는 옵션이 있다. 그런데 사용자가 원한다면 자기가 받은 문자열 자체를 그렇게 출력되는 형태로 '수정해' 주는 옵션이 있다(DT_MODIFYSTRING). 이 옵션을 사용한다면 str은 PCTSTR이라는 프로토타입과는 달리 쓰기 가능한 포인터여야 한다.
또한 RECT *인자도 좀 아쉬운 구석이 있다. 일단은 in/out 겸용이어서 RECT *이지만, 이것도 out 없이 문자열을 찍을 영역만 지정하는 in 전용이 가능했으면 좋겠다.

끝으로, 리스트/트리 같은 공용 컨트롤에 데이터를 추가하는 LVITEM이 있다. 이들 구조체의 pszText는 PCTSTR이 아니라 PTSTR이다. 문자열을 얻어 올 때와 지정할 때 모두 동일한 구조체가 쓰이기 때문에 const 속성이 없는 것이다.
이 때문에 컨트롤에다 데이터를 처음 추가할 때 item.pszText=const_cast<PSTR>("Hello") 같은 연산이 부득이 쓰이기도 한다.

C++과는 달리 자바에는 클래스 멤버 함수라든가, call-by-reference로 전달되는 데이터에 대해서 const-ness를 보증하는 개념이 없다. 클래스에서 Get*으로 시작하는 메소드라고 해서 const를 따로 지정하지는 않으며, final 키워드는 함수의 오버라이드 불가, 값의 변경 불가 같은 '종결자' 용도로만 쓰인다. 과연 const가 없어도 괜찮은 걸까?

그런데 포인터의 단계가 복잡해지다 보면, const가 있어 봤자 가리키는 놈이 const인지, 나 자신이 const인지 일일이 따지고 추적해서 내부 상태의 불변성을 보장하겠다는 시도가 별 의미가 없어지긴 한다. 즉, this가 this와 *this까지 몽땅 봉인되어서 나 자신의 멤버 값은 변경할 수 없지만.. 내가 가리키는 메모리 영역의 값을 한 단계 거칠 수가 있으며 거기 값을 변경하는 게 가능하다면.. 궁극적으로 그 개체의 속성/상태는 바뀌었다고 볼 수 있기 때문이다.

어떤 코드를 실행해 보지 않고 메모리 접근이 안전한지 입증하는 정적 분석 기능이 만들기 까다로운 것도 본질적으로 이런 이유 때문이다. 컴파일러의 경고가 동작을 예측하고 비정상을 지적해 줄 수 있는 것도 간단한 지역변수 수준일 뿐이지, 몇 단계짜리 포인터에 구조체가 줄줄이 따라오면.. 답이 없다.

거기에다 클래스의 인터페이스 상으로는 Set*이 아니라 Get*처럼 "read-only"이고 값이 변하지 않는 기능만 사용한다고 하더라도, 클래스의 내부 상태는 얼마든지 바뀔 수가 있다. 한번 구해 놓은 계산값을 다음에는 빠르게 되돌리기 위해서 그 값 자체 내지 중간 상태를 캐싱하는 것처럼 말이다. 이런 경우가 워낙 많으니 C++에서는 나중엔 멤버의 const-ness에 예외를 허용하는 mutable 키워드도 도입하지 않을 수 없었다.

이건 마치 고유명사와 보통명사의 경계와도 비슷한 맥락의 문제인 것 같다. 로마자 알파벳이 한글과는 달리 대문자가 있고 고유명사는 첫 글자를 대문자로 적게 돼 있어서 일면 편리한 건 사실이다. 하지만 좀 깊게 생각을 해 보면, 새로운 명칭이 만들어졌을 때 그걸 고유명사로 볼지 기준이 생각만치 엄밀하지는 않은 경우가 많다. 기준이 생각만치 엄밀하지 않으며 그냥 자의적이다. 번역하지 않고 음역하는 놈은 죄다 고유명사인 걸까? 실체가 지구상에 오로지 하나밖에 없는 놈? 그렇다면 실물이 존재하지 않는 무형의 개념은? 그걸 정하는 기준은 무엇?

지금 우리가 보통명사로 별 생각 없이 쓰는 명칭들도 그 명칭이 처음 등장했던 시절엔 첫 글자를 대문자로 적는 유니크한 고유명사이기도 했다. const 개념 구분도, 대소문자 구분도 있건 없건 어느 하나가 다른 하나보다 절대적으로 우월하고 좋은 솔루션은 아니어 보인다는 생각이 든다.

에휴, 클립보드부터 시작해서 유니코드, const 등 별별 이야기가 다 이어져 나왔다. 이상~~ ^^

Posted by 사무엘

2016/02/12 08:36 2016/02/12 08:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1192

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

Leave a comment

C/C++에서 대괄호 [ ]는 일단 배열과 관련이 있는 문자이다.
타입을 선언하는 문맥에서 a[...]라고 하면 a가 배열임을 나타내고 선언한다. [] 안에는 배열의 크기를 나타내는 정수 상수(= 컴파일 타임 때 바로 값이 결정되는)만이 올 수 있다.
그 반면 값을 계산하는 문맥에서 a[...]는 일단은 배열이나 포인터를 역참조하는 *(a+...)와 동치가 되기 때문에, 상수가 아닌 임의의 변수값이 들어올 수 있다. 게다가 C++에서는 []를 연산자로서 오버로드도 할 수 있으므로 정수가 아닌 다른 타입의 값이 들어오는 게 가능하다.

그럼 속에 아무것도 없이 말 그대로 []만 달랑 오는 건 언제 가능할까?
일단 값을 계산하는 실행 문맥에서는 전혀 가능하지 않다. 함수 호출이 아닌 일반 수식에서 일반 괄호가 ()요렇게만 달랑 올 수가 없는 것과 같은 이치이다. 또한 인자가 전혀 없는 형태로 [] 연산자 함수를 오버로딩 하는 것도 안 된다.
얘가 쓰이는 가장 유용하고 편리하고 직관적인 상황은, 이니셜라이저로부터 크기를 자동으로 유추할 수 있는 배열을 선언하여 곧장 초기화할 때이다.

char pt[] = "Hello"; //null 문자까지 합해서 6이라는 크기가 자동으로 유추됨.
static const int samp[] = {1, 5, 7, 8}; //4라는 크기가 자동으로 유추됨.

이 정도야 []는 명백하게 배열을 나타내며, 그 의미를 충분히 납득할 수 있다. 헷갈릴 게 없다.
그리고 지역변수 말고 다른 번역 단위에 있는 배열을 요런 게 있다고 선언만 해 놓는 문맥일 때도 구체적인 크기가 생략된 arr[] 같은 구문이 올 수 있다. 이 경우 클래스로 치면 forward 선언과 얼추 비슷한 역할을 한다. 배열을 참조하는 코드를 생성할 수는 있지만 sizeof 연산자는 쓸 수 없게 된다. 온전한 배열은 아니지만 포인터도 아니기 때문에 여기에 다른 임의의 주소를 대입할 수는 없다.

그런데 []는 일부 제한된 문맥과 범위에서는 그냥 포인터를 나타낼 때도 있다. 이거 다시 보니 굉장히 요물스럽게 느껴진다.

void foo(char ptr[]);
void foo(char *p);

위와 아래의 함수 선언은 완전히 동치이다. 그렇기 때문에 저 두 쌍으로는 함수 오버로딩을 할 수 없다.
이게 요물스럽게 느껴지는 이유는, (1) 첫째, 함수의 인자에서만 사용 가능한 문법이기 때문이다.
일반 변수를 선언할 때야 char ptr[]은 포인터가 아니라 배열로 인식된다. 그렇기 때문에 이니셜라이저 없이 저렇게만 달랑 써 놓으면 크기를 알 수 없다고 컴파일 에러가 난다.
그에 반해 함수 인자에서는 배열을 선언하거나 초기화 따위 할 일이 없으니 ptr[]은 *p와 동일하게 인식된다.

우리는 프로그램으로부터 명령 인자를 받기 위해 main 함수로부터 argc, argv 인자를 살펴본다. 그런데 정말 이상한 건 argv는 거의 언제나 관행적으로 *argv[]라고 선언해 놓고 써 왔다는 것이다. 이건 알고 보면 **argv와 본질적으로 완전히 동치일 뿐인데도 말이다. 하지만 *argv[]와 **argv는 왠지 느낌상 서로 동일해 보이지가 않는다.

함수 인자와는 달리 리턴값은 char *bar()만 가능하지 char []bar 이렇게 쓸 수 없다. 함수 인자 말고는 리턴값이나 지역 변수 선언, 형변환 연산자, typedef 등 타입을 명시하는 그 어떤 문맥에서도 []를 저런 식으로 쓰는 건 문법적으로 가능하지 않다.

(2) 둘째, []는 그렇다고 해서 함수 인자이기만 하면 *와 완전히 등가 교환이 가능한 것도 아니다.
[]의 지원 범위는 오로지 1중 포인터 한정이다. 2중 포인터인 char **a를 a[][] 따위로 대체할 수는 없으며 오로지 한 단계만 *a[]로 대체할 수 있을 뿐이다. 한 단계 포인터를 대체할 수 있다는 점에서는 C++의 참조자 &를 살짝 닮은 것 같기도 하다.

그럼 함수 인자에서는 왜 저걸 예외적으로 가능하게 해 놓은 것일까?
정확한 이유는 알 수 없지만 C/C++은 배열은 함수 인자로 전달할 때 값이 새로 복제되는 게 아니라, 언제나 주소(= 포인터) 형태로만 전달한다는 점을 부각시키기 위해서 저렇게 한 게 아닌가 싶다. N차원 배열은 언제나 N-1차원 배열의 1중 포인터가 된단 얘기다. 다차원 배열을 선언해서 함수로 전달하는 상황을 생각해 보자.

int table[][2][4]={
    {{1,2,3,4}, {3,4,5,6}}, {{2,6,2,3}, {5,6,3,4}}, {{1,}, {2,}}
};

foo(table);

사실 C/C++은 엄밀히 말하면 다차원 배열이라는 것도 없고 그저 '배열의 배열'이 있을 뿐이다.
2*4 배열의 배열을 선언한다는 건 생략할 수 없고, 유일하게 생략할 수 있는 건 제일 겉에 있는 최종 원소의 개수(저기서는 3)이다.
이런 3차원 배열을 인자로 받는 함수는 프로토타입을 어떻게 만들면 좋을까?

void foo(int a[][2][4]);

table은 함수의 인자로 전달할 때는 그대로 2*4 배열의 포인터가 되고, 그걸 함수 인자로 표현하는 걸 배열 변수를 선언할 때와 동일하게 [][2][4]로 할 수가 있다. 함수 인자에서 []의 의미는 바로 여기에 있다고 보는 게 타당할 것이다. 함수 인자에서도 [] 안에 숫자를 생략할 수 있는 건 제일 겉의 차원수 하나뿐이다.

위의 함수의 인자는 개념적으로 아래와 완전히 동일하다. *(a+2)와 a[2]가 동일한 것만큼이나 서로 동일하다.

void Fune(int (*a)[2][4]);

a[][2][4]를 사용하지 않았으면 그냥 포인터나 다중 포인터가 아니라 '배열의 포인터'를 표현하기 위해 저렇게 괄호를 쳐야 했을 텐데 [] 표기는 괄호가 또 들어갈 필요를 없애 주고 표현을 간결하게 만들어 준다.
배열 선언 table[][2][4]에서는 []는 원소 개수가 이니셜라이저에 명시돼 있다는 뜻이고, 함수 인자 a[][2][4]에서는 이게 포인터라는 뜻이라니..;;

이건 무슨 최신 C++ 기능도 아니고 C의 완전 기초 필수 아이템인데도... 본인조차 []의 의미와 용도에 대해서 제대로 진지하게 생각을 안 했던 것 같다. 신기하기 그지없다.

Posted by 사무엘

2016/02/04 08:27 2016/02/04 08:27
,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1189

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

Comments List

  1. Lyn 2016/02/04 09:04 # M/D Reply Permalink

    요즘은 Capture 없음을 나타내기도 ...

    1. 사무엘 2016/02/04 09:38 # M/D Permalink

      람다에서는 []에다 ->까지 완전 환골탈태했지요.

      원래는 a[b]는 *(a+b) 와 동치, a->b 는 (*a).b와 동치여서 각각 배열· 구조체를 포인터를 접목시킨 문법입니다만 람다에서는 전혀 무관한 의미로... =_=;;;

  2. 경헌 2016/02/04 10:27 # M/D Reply Permalink

    어쩌면 그냥 * 와 같은거니 [] 는 신경쓰지 말아라! 하고 결론을 내릴 수도 있겠네요..

    저도 헷갈릴까봐 항상 그냥 포인터를 써 왔었는데, 잘 정리해 주셔서 감사합니다!

    1. 사무엘 2016/02/04 22:20 # M/D Permalink

      네, 저도 동의합니다. 최종 타입이 포인터를 의미한다면 언제나 *만 쓰는 게 덜 헷갈릴 것 같습니다. []는 배열이라는 고정된 의미와 용도가 있으니까요. ^^

Leave a comment

컴파일러의 경고 외

군대 유머, 관제탑 유머가 있는 것처럼 변호사를 소재로 한 블랙코미디 시리즈가 있다.
돈만 주면 자기 양심과 혼까지 팔아서 온갖 미사여구와 궤변(?)으로 범죄자의 형량을 감소시키고 심지어 무죄로 조작한다는.. 변호사에 대한 좀 과장되고 왜곡된 이미지가 들어가 있다.

천당과 지옥(혹은 천사와 악마)이 법정에서 소송이 붙으면 천당/천사 진영은 아마 승산이 없을 거라는 개드립조차 있다. 왜냐하면 유능한(=타락한-_-) 변호사들은 몽땅 지옥에 가 있어서 다 악마 편이기 때문에. -_-;; 물론 영적 법정에서 실제로 하나님이 어떤 편인지를 안다면, 그리고 성경에서 judgment라든가 judge라는 단어의 용례만 쭉 뽑아 보면 개드립은 그냥 개드립일 뿐이라는 걸 알 수 있다.

그런데 변호사가 굉장히 바보 같은 질문을 할 때가 있(었)는가 보다. 예를 들어..

  • 그림을 도둑맞던 당시에 선생님/고객님은 현장에 계셨습니까?
  • 그 일을 혼자 하셨나요? 아니면 단독 범행?
  • 충돌 당시에 두 차가 얼마나 떨어져 있었죠?
  • 그 스무 살 먹었다는 막내아들이 나이가 어떻게 된댔죠?
  • 전쟁에서 죽었다는 사람이 당신이었습니까, 아니면 당신 동생이었습니까?
  • 건망증을 앓고 계셨다면, 그럼 그 동안 잊어버린 것들의 예를 좀 들어 주시죠.

도대체 저 변호사 양반이 왕년에 그 무시무시한 사법 시험을 어떻게 통과했는지, 아니면 악착같은 공부 기계 괴수들이 몰리는 로스쿨을 어떻게 들어가서 졸업했고 어떻게 변호사 시험을 합격했는지를 의심케 하는 대목이 아닐 수 없다.

바보같은 질문은 그 변호사가 너무 격무에 시달린 나머지 (1) 정말로 뇌에 나사가 좀 풀려서 감을 잃었거나, (2) 정신 없어서 의뢰인을 완전 성의없게 대해서 나올 수 있다. 하지만 한편으로는 (3) 일부러 바보 같은 질문을 던져서 일종의 심문을 하려는 의도도 있다. 같은 내용을 비비 꼰 바보 같은 질문에 낚여서 진지하게 대답하다 보면, 일관성 없는 진술이 들통날 수 있기 때문이다.

뭐, 여기서 내가 법조인들의 심리나 심문 기법 같은 걸 얘기하려는 건 아니고.
중요한 건, 자연 언어뿐만 아니라 프로그램 코드도 사람이 작성하는 것이다 보니 저런 바보 같은 문장이 있을 수 있다는 것이다. 그리고 컴파일러가 그걸 지적해 주는 것을 우리는 '경고'라고 부른다.

간단한 예로는 선언만 해 놓고 사용하지 않은 변수, 초기화하지 않고 곧장 참조하는 변수, 한쪽에서는 class로 선언했는데 나중에 몸체를 정의할 때는 동일 명칭을 struct로 규정한 것이 있다. 딱히 에러까지는 아니고 코드 생성이 가능하지만, "혹시 다른 걸 의도한 게 아니었는지" 의심할 만한 부분이다.

더 똑똑한 컴파일러는 세미콜론이나 =/==사용이 아리까리해 보이는 것도 경고로 찍으며, 이런 것도 지적해 준다.
unsigned long p; (...) if(p<0) { }

unsigned 타입의 변수를 보고 "너 혹시 0보다 작니?"라고 묻는 건 그야말로 변호사가 "당신과 당신 동생 중 전쟁에서 죽은 사람이 누구라고 했죠?"라고 묻는 것이나 다름없다. 그러니 저 if 안에 있는 코드는 unreachable이라고 지적해 주는 건 적절한 조치이다.

사실, 사람이라 해도 처음부터 대놓고 저렇게 바보 같은 문장을 작성하는 경우는 드물다. 작성한 지 오래 된 코드를 나중에 리팩터링이나 다른 수정을 하게 됐는데, 같이 고쳐져야 하는 문장이 일부만 고쳐져서 일관성이 깨지는 경우가 더 많다. 남이 int를 기준으로 작성해 놓은 코드를 나중에 후임이 UINT로 고치면서 저 if문의 존재를 잊어버린다거나(알고 보니 이 값에 음수가 들어오거나 쓰일 일은 절대 없더라). 버그도 이런 식으로 생기곤 한다.

비주얼 C++에서 경고는 총 4단계가 있다. 1단계는 정말로 말이 안 되어 보이는 것만 출력하고, 4단계까지 가면 정말 미주알고주알 별걸 다 의심스럽다고 지적한다. 경고들을 그렇게 여러 단계로 분류한 기준은 딱히 표준이 있지는 않고 그냥 컴파일러 제조사의 임의 재량인 것으로 보인다.

비주얼 C++이 프로젝트를 만들 때 지정하는 디폴트는 3단계이다. 3단계를 기준으로 깔끔하게 컴파일되게 작성하던 코드를 4단계로 바꿔서 빌드해 보면 이름 없는 구조체를 포함해서 사용되지 않은 '함수 인자'들까지 온통 경고로 뜨기 때문에 output란이 꽤 지저분해진다. 물론, 특정 경고를 그냥 꺼 버리는 #pragma warning 지시문도 있지만, 그 자체가 소스 코드를 지저분하게 만드는 일이기도 하고.

그러니 어지간하면 3단계만으로 충분하지만, 4단계 경고 중에도 컴파일러가 잡아 주면 도움이 되겠다 싶은 일관성 미스 같은 것들이 있다.
그래서 모든 사람들이 코드의 모든 구조를 알지 못하는 공동 작업을 하는 경우.. (직감보다 시스템이 차지하는 비중이 더 커짐) 그리고 팀원/팀장 중에 좀 결벽증 강박관념이 있는 사람이 있는 경우, 4단계를 기준으로 프로젝트가 진행되며, 커밋하는 코드는 반드시 경고와 에러가 하나도 없어야 한다고 못을 박곤 한다. 심지어 경고도 에러와 동등하게 간주시켜서 빌드를 더 진행되지 않게 하는 컴파일 옵션을 사용하기도 한다.

변호사 유머를 보니까 컴파일러의 경고가 생각이 나서 글을 썼다.
내 생각엔 a=a++처럼 이식성 문제가 있고 컴파일러 구현체마다 다른 결과가 나올 수 있는 코드에 대해서나 경고가 좀 나와 줬으면 좋겠다. 저것도 초기화되지 않은 변수만큼이나 문제가 될 수 있기 때문이다. 비주얼 C++의 경고 level 4 옵션으로도 저건 그냥 넘어가는 듯하다.

예전에도 얘기한 적이 있듯, 법은 사람을 제어하는 일종의 선언/논리형 프로그래밍 언어로서 컴퓨터 사고방식으로 생각할 수도 있는 물건이다. 또한, 프로그램의 버전을 얼마나 올릴지 결정하는 게 형벌의 양형 기준과 비슷한 구석이 있다.
잡다한 기능들을 많이 추가한 것, 짧고 굵직한 기능을 구현한 것, 비록 작업량은 별로 많지 않지만 현실에서의 상징성과 의미가 굉장히 큰 것, 아니면 그냥 적당히 시간이 많이 흘렀기 때문에 숫자를 팍 올리는 것.

형벌이라는 것도 사람을 n명 죽인 것에 비례해서 징역이 올라가는 그런 관계는 당연히 아닐 테니, 상당히 많은 변수들이 감안된다.
이런 것들을 다 감안해서 다음 버전의 숫자를 결정하는데 이거 굉장히 복잡하다.

Posted by 사무엘

2015/12/10 08:33 2015/12/10 08:33
, , ,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1169

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

Comments List

  1. 사포 2015/12/10 09:55 # M/D Reply Permalink

    비유가 상당히 참신하네요! 잘 읽고 갑니다 ^^

    1. 사무엘 2015/12/10 14:23 # M/D Permalink

      글들을 재미있게 읽고 꾸준히 의견도 남겨 주셔서 고맙습니다. ^^
      저는 바로 전에 음악 관련 글을 썼는데, 사포 님도 작곡 스킬이 있으신 것 같네요!

  2. 사포 2015/12/15 10:10 # M/D Reply Permalink

    ㅎㅎ 넹 중딩 때부터 취미로 간간히 해오고 있습니다. 재밌어요 헤헤...

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2017/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:
883304
Today:
233
Yesterday:
416