본인은 최근에 직장일 때문에 이메일을 자동으로 생성해서 보내는 프로그램이란 걸 난생 처음으로 작성해 봤다. 소켓 API만 써서 말이다.
서식이고 첨부고 몽땅 다 생략한 최소한의 형태만 생각한다면, 이메일을 보내는 것 자체는 내 예상보다 굉장히 간편하게 쉽게 자동화할 수 있는 일이라는 걸 알 수 있었다. SMTP 서버 명령어 몇 개만 스펙대로 주고 받으면 된다.

발신자는 말할 것도 없고 수신자조차도 실제로 수신하는 대상과 화면에 표시되는 수신자가 서로 다르게 얼마든지 조작 가능하다. 스팸 메일을 대량으로 살포하는 건 일도 아니겠다는 걸 이제야 느꼈다. 이런 문제도 있고, 또 이메일 내용을 다른 해커가 가로챌 수도 있으니 이 바닥에도 온갖 복잡한 인증과 암호화 계층이 나중에 도입된 거지 싶다.

이메일과 관련하여 서버에다 요청을 보낼 때는 줄 바꿈 문자가 \n이 아니라 반드시 \r\n을 써야 한다는 게 인상적이었다. 이건 어째 유닉스가 아닌 DOS/Windows 진영의 관행과 일치한다. 그리고 메일 본문의 끝을 의미하는 게 도스의 copy con이 사용하는 Ctrl+Z 같은 제어 문자가 아니라 그냥 "빈 줄+마침표+빈 줄"이다.

또 주목할 만한 것은 DATA(본문)에 들어가는 발신 날짜였다. 난 메일을 보내면 발신 시각 정도는 메일 서버가 자기 시각을 기준으로 당연히 자동으로 넣어 줄 거라고 생각했다. 사람이 이메일을 보낼 때 발신 시각을 일일이 써 넣지 않듯이 말이다.
하지만 내부적으로는 그렇지 않았다. 보내는 쪽에서 알려 줘야 하며, 허위로 조작된 임의의 날짜· 시각을 보내는 것도 얼마든지 가능하다.

그리고 여기에 써 주는 날짜· 시각은 "Tue, 18 Nov 2014 13:57:11 +0000" 같은 형태로, 날짜와 시각, time zone을 모두 포함하고 있다. 심지어 요일이라는 일종의 잉여 정보도 있다.
이 형식은 RFC 2822에 표준 규격으로 정해졌는데, 보다시피 사람이 읽기 편하라고 만들어졌지 컴퓨터의 입장에서 간편하게 읽고 쓸 수 있는 형식은 아니다. 컴퓨터의 관점에서는 그냥 1970년 1월 1일 이래로 경과한 초수, 일명 Unix epoch 숫자 하나가 훨씬 편할 텐데 말이다. time zone도 무시하고 UTC만 통용시키고 말이다.

실제로 이 날짜· 시각 문자열은 그 형태 그대로 쓰이지 않는다. 어차피 이메일 클라이언트가 파싱을 해서 내부적으로 Unix epoch 같은 단순한 형태로 바꿔야 한다. 그래야 당장 메일들을 오래된 것-새것 같은 순서대로 정렬해서 목록을 뽑을 수 있을 테니 말이다. 또한 그걸 출력할 때는 "2014년 11월 18일 오후 10시 57분 11초"처럼 사용자의 언어· 로케일과 설정대로 형태가 또 바뀌게 된다.

그러니 사람보다는 기계가 더 활용하는 날짜 시각 문자열 포맷이 왜 저렇게 복잡한 형태로 정해진 건지 의문이 들지 않을 수 없다. 읽고 쓰기 위해서 달 이름과 요일 이름 테이블까지 참조해야 하고 말이다. 글쎄, SMTP 명령어를 사람이 직접 입력해서 이메일을 보내던 엄청난 옛날에 사람이 읽고 쓰기 편하라고 저런 형태가 정해진 건지는 모르겠다.

Windows API의 GetDateFormat/GetTimeFormat 내지 C 언어의 asctime/ctime 함수 어느 것도 이메일의 날짜· 시각 포맷과 완전히 일치하는 문자열을 되돌리지는 않는다. 특히 C 함수의 경우,

Tue Nov 18 13:57:11 2014

로, 년월일 시분초 요일까지 정보가 동일하고 맨 처음에 요일이 나오는 것까지도 일치하지만.. 이메일 포맷과는 일치하지 않는다. C 함수도 나열 순서와 글자수가 언제나 동일하고 불변인 것이 보장돼 있기 때문에 저걸 변경할 수는 없다. 저 결과값을 그대로 쓸 수도 없으니 답답하기 그지없다.

참고로 일반인이 저런 날짜· 시각 format 함수를 작성한다면 그냥 단순무식하게 sprintf "%02d %s" 같은 방식으로 코딩을 하겠지만, 프로그래밍 언어 라이브러리에서는 그런 짓은 하지 않고 성능을 최대한 중요시하여 각 항목들을 써 넣는 것을 한땀 한땀 직접 구현한다. 해당 라이브러리의 소스를 보면 이를 확인할 수 있다.

Windows API에는 SYSTEMTIME이라는 구조체가 있고, C에는 tm이라는 구조체가 있어서 날짜와 시각을 담는 역할을 한다. 그런데 tm이라니.. 구조체 이름을 무슨 변수 이름처럼 참 이례적으로 짧고 성의없게 지은 것 같다. -_-
C 시절에는 앞에 struct라는 표식을 반드시 덧붙이기라도 해야 했지만 C++은 그런 것도 없으니 더욱 난감하다. C++의 등장까지 염두에 뒀다면 이름을 절대로 저렇게 지을 수 없었을 것이다.

또한 tm 구조체의 멤버들 중 월(tm_mon)은 1이 아니라 0부터 시작한다는 것, 그리고 연도(tm_year)는 실제 연도에서 1900을 뺀 값을 되돌린다는 것도 직관적이지 못해서 번거롭다. 즉, 2019년 7월은 각각 119, 6이라고 기재된다는 것이다. 그에 반해 Windows의 SYSTEMTIME은 그렇지 않으며 wYear과 wMonth에 실제값이 그대로 들어가 있다.

월이 0부터 시작하는 건 쟤네들 문화권에서는 어차피 월을 숫자가 아닌 이름으로 취급하기 때문에 배열 참조의 편의를 위해서 그렇게 했을 것이다. 그런데 연도는.. 무슨 공간을 아끼려고 굳이 1900을 뺐는지 모르겠다. 16비트 int 기준으로 서기 32767년만 해도 정말 까마득하게 먼 미래인걸 말이다.

아 하긴, 20세기 중후반엔 연도의 마지막 두 자리(10과 1)만 써도 70이니 90이니 하면서 월과 일의 숫자 범위보다 월등히 컸기 때문에 변별력이 있었다. 연도도 두 자리만 쓰는 게 관행이었기 때문에 1900을 빼는 것은 그런 관행을 반영한 조치였을 것이다. Office 97은 있어도 Office 07은 없고 2007이니까 말이다.

엑셀 같은 스프레트시트들도 날짜 겸 시각을 저장하는 자료형의 하한이 1900년 1월 1일로 잡혀 있다. 그래서 한국 최초의 철도가 개통한 1899년 9월 18일 같은 날짜는 아슬아슬하게 날짜형으로 저장하지 못하며, 일반 문자열로만 취급된다. =_=;;

이렇게 인간 가독형 날짜· 시각 말고 기계 가독형으로 직렬화된 날짜· 시각을 저장하는 정수 자료형으로 Windows에는 FILETIME이 있고, C에는 time_t가 있다. FILETIME은 Windows NT 시절부터 64비트로 시작했지만 후자는 2000년대 이후에 와서야 2038년 버그를 미연에 방지하기 위해 각 플랫폼별로 64비트로 확장됐다. 사실, 이때부터 PC도 64비트로 바뀌어서 플랫폼에 따라서는 long 같은 자료형도 64비트로 바뀌기도 했다..

그리고 요일이야.. 두 구조체 모두 일요일이 0이고 토요일이 6이다.
요일도 이름이 아닌 숫자로만 취급하는 언어는 내가 알기로 중국어밖에 없는데 혹시 더 있는지 궁금하다. 물론 인간의 언어에는 설마 0요일이 있을 리는 없고 1부터 시작할 텐데.. 중국어의 경우 일요일은 日이어서 이게 0 역할을 하고, 월요일부터 토요일까지가 1요일~6요일에 대응한다.

이상이다. 이메일 얘기로 시작해서 날짜 시각 얘기로 소재가 바뀌었는데..
이메일(POP3/SMTP)을 비롯해 HTTP, FTP 같은 표준 인터넷 프로토콜들을 클라이언트와 서버를 모두 소켓 API만으로 직접 구현해 보는 건 코딩 실력의 향상에 도움이 될 것 같다. 일상생활에서야 이미 만들어져 있는 솔루션만 사용하면 되니까 실용적인 의미는 별로 없겠지만, 학교에서 학술..도 아니고 그냥 교육적인 의미는 충분히 있을 테니 말이다. 내부 구조를 직접 살펴보면, 이런 프로토콜의 secure 버전이 왜 따로 만들어져야 했는지 이유도 알게 될 것이다.

더구나 이를 응용해서 특정 메일에 대해 자동 회신을 보내는 프로그램, 한 FTP 서버의 파일을 다른 FTP 서버로 올리는 프로그램까지 만드는 건 시험이나 과제 용도로도 괜찮아 보인다. 요즘은 FTP 같은 거 명령어로 이용할 줄 아는 사람이 얼마나 될까? 당장 본인도 모른다. ㅎㅎ

이메일을 쓰지 않는다는 도널드 커누쓰 할배가 문득 생각난다. 이분이야 뭐 1970년대, 컴퓨터 네트워크라는 건 그냥 기업· 연구소, 정부 기관만의 전유물로 여겨졌고 이메일이란 게 처음으로 발명됐던 시절에 그걸 다뤄 왔던 분이다. 그러다가 현역 은퇴 후에 때려치우고 온라인 공간의 속세와 단절한 셈이다. 이젠 뭐가 아쉬워서 누구에게 이메일로 연락을 하거나 연락을 기다려야 할 처지도 전혀 아니고.. 그냥 아날로그 종이 편지를 취급하는 것만으로 충분하시댄다.

Posted by 사무엘

2019/12/18 08:32 2019/12/18 08:32
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1695

C/C++은 어떤 명칭에 대해 선언과 정의의 구분이 명확한 축에 드는 언어이다. 정의는 선언도 같이 포함하지만 그 역은 성립하지 않는다. 전자는 심판의 선고이고, 후자는 집행이라고 봐도 되겠다.

(1) 함수: 실행되는 코드를 담고 있기 때문에 {}에 둘러싸여 정의된 몸체의 존재감이 압도적인 물건이다. 또한 함수의 선언부는 자신의 프로토타입(인자의 개수와 타입, 리턴값의 타입)을 나타내기도 한다. 그렇기 때문에 얘는 소형 인라인 형태가 아닌 이상, 선언과 정의의 구분이 가장 명확하다.

(2) 자료형: 구조체나 클래스는 함수보다야 선언 따로 정의 따로일 일이 훨씬 드물다. 하지만 헤더에서는 포인터 형태만 사용하는데 쓸데없는 #include 의존성을 또 만들지 않기 위해 class Foo; 같은 불완전한 타입을 선언만 하는 게 가능은 하다. 마치 함수 선언처럼 말이다.
선언만 존재하는 불완전한 타입은 sizeof 연산자를 적용할 수 없으며, 포인터형의 경우 *나 ->로 역참조해서 사용할 수도 없다.

(3) static 멤버/전역 변수: 변수는 선언하는 것 자체 말고 딱히 {}로 둘러싸인 세부 정의가 존재하지 않는다. 생성자 인자라든가 초기화 값(initializer)이 쓰이긴 하지만 그건 definition, body와는 성격이 완전히 다른 정보이니 말이다.

다만, 지역 변수 말고 클래스의 static 멤버에 대해서는 static int bar와 int Foo::bar 같은 선언/정의 구분이 존재한다. 그리고 전역 변수도 extern이라고 선언된 놈은 정의가 아닌 선언 껍데기일 뿐이다. (실제 definition은 다른 translation unit에 존재한다는..)
사실, global scope에서 함수의 선언도 앞에 extern이 생략된 것이나 마찬가지이다. 지역 변수의 선언들이 모두 구 용법의 auto가 생략된 형태인 것처럼 말이다.

함수건 변수건 선언은 여러 군데에서 반복해서 할 수 있지만 몸체 정의는 딱 한 군데에만 존재한다. 이는 마치 분향소와 빈소의 관계와도 비슷해 보인다.
이런 선언부에서는 배열의 경우 그 구체적인 크기를 생략할 수 있다. * 대신 []을 써서 얘는 정확한 크기는 모르지만 어쨌든 포인터가 아닌 배열이라고 막연하게 선언할 수 있다는 것이다. 그리고 const 변수는 초기화 값이 선택이 아니라 필수인데, 이 역시 선언 단계에서 생략될 수 있다.

1. 함수와 구조체: 상호 참조를 위한 불완전한 전방 선언

(1) 함수나 (2) 구조체/클래스는 상호 참조를 할 수 있다. A라는 함수에서 B를 호출하고, B도 A를 호출할 수 있다. 또한, X라는 구조체에서 Y라는 구조체의 포인터를 멤버로 갖는데, Y도 내부적으로 X의 포인터를 갖고 있을 수 있다.

요즘 프로그래밍 언어들은 구조적으로 같은 소스 코드를 두 번 읽어서 파싱하게 돼 있기 때문에 한 함수에서 나중에 등장하는 다른 함수를 아무 제약 없이 참조할 수 있다. C++도 그런 요소가 있기 때문에 한 클래스의 인라인 멤버 함수에서 클래스 몸체의 뒷부분에 선언된 명칭에 곧장 접근할 수 있다. 즉, 다음과 같은 코드는 컴파일 된다.

class Foo {
public:
    void func1() {
        func2();
    }
    void func2() {
        func1();
    }
};

하지만 global scope에서 이런 코드는 적어도 C++ 문법에서는 허용되지 않는다.

void Global_Func1() {
    Global_Func2();
}
void Global_Func2() {
    Global_Func1();
}

맨 앞줄에 void Global_Func2(); 이라고 Global_Func2라는 명칭이 껍데기만이라도 forward(전방) 선언돼 있어야 한다. 파스칼 언어에는 이런 용도로 아예 forward라는 지정자 키워드가 있기도 하다.
매우 흥미로운 것은..

struct DATA1 {
    DATA2* ptr;
};
struct DATA2 {
    DATA1* ptr;
};

이렇게 구조체끼리 상호 참조를 하기 위해서는..
심지어 클래스 안의 구조체라 하더라도 앞에 struct DATA2는 반드시 미리 전방 선언이 돼 있어야 한다는 것이다. 클래스 안에 선언된 멤버 함수와는 취급이 다르다. 왜 그런 걸까? 멤버 함수의 몸체는 클래스 밖에 완전히 따로 정의될 수도 있지만 구조체의 몸체는 그럴 수 없다는 차이 때문인 듯하다.

원래 파스칼과 C는 옛날에 컴파일러의 구현 난이도와 동작 요구 사양을 낮추기 위해, 소스 코드를 한 번만 읽으면서 곧장 parsing이 가능하게 설계되기도 했다. 모든 명칭들은 사용되기 "전에" 정의까지는 아니어도 적어도 선언은 미리 돼 있어야 컴파일 가능하다. 아무 데서나 '정의'만 한번 해 놓으면 아무 데서나 그 명칭을 사용할 수 있는 그런 자유로운 언어가 아니라는 것이다.

함수와 전역 변수의 경우, 그 다음으로 몸체 정의를 찾아서 실제로 '연결'하는 건 잘 알다시피 링커가 할 일이다. 단지, 구조체/클래스는 몸체가 당장 컴파일 과정에서 그때 그때 쓰이기 때문에(멤버의 타입과 오프셋...) 링크가 아닌 컴파일 단계에서 실제 몸체를 알아야 한다는 차이가 있다.

불완전한 타입에 대해서 거기에 소속된 구조체/클래스를 불완전한 형태로 또 중첩 선언하는 것은 가능하지 않다.

class A;
class A::B;

A의 몸체를 모르는 상태에서 연쇄적으로 B를 저렇게 또 선언할 수는 없다는 것이다. 그걸 허용하는 건 C++을 동적 타입 언어급으로 만드는 너무 사악한(?) 짓이 될 것 같다. 특히 이미 자유도가 너무 높은 템플릿을 구현하는 것까지 생각했을 때 말이다.
실체가 없는 저런 자료형의 포인터를 무리하게 만들 바에야 아예 void* 포인터를 그때 그때 캐스팅해서 쓰고 말겠다. 아니면, 저런 식으로 다단계 scope 구분만 하는 게 목적이라면 클래스 대신 namespace라는 훌륭한 대체제가 있다.

2. 구조체: 전방 선언과 다중 상속 사이의 난감함

이렇게 몸체를 모르는 클래스를 불완전 전방 선언만 해서 쓰는 것은 일면 편리하지만.. C++이 제공하는 다른 기능 내지 이념과 충돌해서 난감한 상황을 만들 때도 있다.
즉, class X와 class Y라고 이름밖에 모르던 시절에는 X와 Y는 서로 완전히 남남이며, 포인터 형변환도 오프셋 보정 없이 단순무식한 C-style로만 하면 된다.

그런데 알고 보니 X와 Y가 다중 상속으로 얽힌 사이라면.. 몸체를 모르던 시절과 알고 난 뒤의 컴파일러의 코드 생성 방식이 서로 달라질 수밖에 없다. 특히 X 내지 Y의 멤버 함수를 가리키는 pointer-to-member 타입의 크기와 구현 방식도 달라지게 된다. X가 전방 선언만 돼서 아무런 단서가 없을 때가 제일 복잡하고 까다롭다.

Visual C++의 경우, 얘가 전방 선언만 됐지만 다중/가상 상속 같은 것 안 쓰는 제일 단순한 형태이기 때문에 pointer-to-member도 제일 단순한 형태로만 구현해도 된다고 단서를 제공하는 비표준 확장 키워드를 자체적으로 제공할 정도이다. 그만큼 C++의 스펙은 복잡 난해하고 패러다임이 서로 충돌하는 면모도 존재한다.

이렇듯, 명칭의 선언과 정의라는 간단한 개념을 고찰함으로써, C/C++ 이후의 언어들은 선배 언어의 복잡 난해함을 어떻게든 감추고 사용자와 컴파일러 개발자의 입장에서 다루기 편한 언어를 만들려고 어떤 개량을 했는지를 알 수 있다. 당장 Java만 해도 헤더/소스 구분 없이 한 클래스에서 각종 함수나 명칭을 수정하면 번거로운 재컴파일 없이도 그걸 다른 소스 코드에서 곧장 사용 가능하니 얼마나 편리한가 말이다.

3. 함수: extern "C"

앞서 살펴본 바와 같이 extern은 static library 형태이든 DLL/so 방식이든 무엇이건, 외부로 노출되는 전역 함수 및 변수 명칭을 선언하는 키워드이다. 그런데 기왕 대외 선언을 하는 김에 노출을 하는 방식도 옵션을 줘서 같이 지정할 수 있다.
모르는 사람에게는 굉장히 기괴해 보이는 문법인 extern "C"가 바로 그것이다. 이건 함수 명칭을 C++ 스타일로 decorate를 할지, 아니면 예전의 C 시절처럼 원래 이름을 변조 없이 그대로 선언할지를 지정한다.

C++에서 변조니 decorate니 해서 굳이 언어의 ABI 차원에서 호환을 깨뜨려야 하는 이유는.. C++에는 C와 달리 함수 인자를 기반으로 오버로딩이 존재하기 때문이다. 그러니 argument의 개수와 타입들에 대한 정보가 이름에 첨가돼야만 이 함수를 그 이름으로 유일하게 식별할 수 있다.

뭐, static 함수는 대외 노출이 아니니 한 번역 단위 안에서 함수 이름이야 어떻게 붙이건 전혀 상관없으며.. C++ 클래스 멤버 함수는 애초에 C언어에서 접근 불가능한 물건이이고 무조건 C++ 방식으로 decoration을 해야 한다. 그러니 extern "C" 옵션이 필요한 곳은 C와 C++이 모두 접근 가능한 일반 전역 함수 정도로 한정된다.

"C" 말고 쓸 수 있는 문자열 리터럴은 "C++".. 요 둘뿐이다. 그리고 "C++"은 디폴트 옵션이므로 signed만큼이나 잉여이고, 오늘날까지도 사실상 "C"만 쓰인다.
만들고 있는 라이브러리가 자기 제품 내부에서밖에 안 쓰이거나, 어차피 소스째로 통째로 배포되는 오픈소스여서 특정 컴파일러의 ABI에 종속되어도 아무 상관 없다면.. 함수를 C++ 형태로, 아니 C++ 클래스 라이브러리 형태로 선뜻 만들 수 있을 것이다.

그게 아니라면 외부 노출 함수 이름은 어느 언어에서나 쉽게 import 가능한 extern "C" 형태로 만드는 게 일반적이다. extern "C" 다음에는 이 구간에서 선언되는 명칭들을 모두 C 방식으로 노출하라고 중괄호 {}까지 줄 수 있으니 생소함과 기괴함이 더해진다.

이건 컴파일러의 구문 분석 방식을 변경하는 옵션이 아니다. {} 안의 코드는 C 문법으로만 해석하라는 말이 절대 아니다. extern "C" 방식으로 선언된 함수의 안에서도 템플릿, 지역 클래스 등 C++ 문법은 얼마든지 사용할 수 있고 타 C++ 객체를 참조할 수 있다. 단지 이 함수는 동일 명칭의 여러 오버로딩 버전을 만들어서 대외적으로 제공할 수 없을 뿐이다.

또한, 컴파일러의 최적화나 코드 생성 방식에 영향을 주지도 않는다. stdcall, pascal, cdecl 같은 calling convention이야 인자를 스택에다 올리는 순서 내지 스택 주소 복귀를 하는 주체(caller or callee)를 지정하는 것이니까 코드 생성 방식에 영향을 준다. 언어 문법 차원에서의 프로토타입이 동일하더라도 calling convention이 다른 함수끼리는 포인터가 서로 호환되지 않는다.
그에 반해 extern "C" 지정이 잘못되면 obj와 lib 사이에 공급된 명칭과 요청한 명칭이 일치하지 않아서 끽해야 링크 에러가 날 뿐이다. 개념이 이렇게 정리된다.

Posted by 사무엘

2019/11/16 08:32 2019/11/16 08:32
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1684

1. 문법 함정

C/C++에서 연산자로 쓰이는 토큰(문자)들 중에는 문맥에 따라서 의미가 중복될 수 있는 것이 있다.
예를 들어 * () [] 같은 토큰은 값을 계산하는 수식에서 쓰일 때와, 변수를 선언할 때 의미가 서로 다르다. 한쪽에서는 인근의 변수가 배열· 포인터· 함수 타입임을 나타내지만, 다른 쪽에서는 실제로 배열 첨자나 포인터를 역참조하고 함수를 호출하는 역할을 한다.

심지어 =조차도 int a=5; 와 그냥 a=5; 에서 =는 문법적인 의미가 서로 동일하지 않다. 똑같이 =를 썼더라도 중괄호를 동원하여 배열이나 구조체를 초기화하는 것은 일반 수식에서는 가능하지 않기 때문이다.

이런 것 말고도 콤마(,)의 경우.. 함수 인자 구분자와 쓰임이 완벽하게 겹친다. 그렇기 때문에 함수 인자에서 콤마 연산자를 쓰려면 수식을 괄호로 싸야 한다.

그리고 <>로 둘러싸인 템플릿 인자에서 부등호 내지 비트 이동 연산자를 쓸 때도 상황이 좀 난감해진다. 템플릿 인자에 typename만 올 때는 <>가 모호성을 전혀 일으키지 않지만, 문제는 템플릿 인자로 정수도 들어올 수 있다는 것이다. 그리고 값이 컴파일 타임 때 결정만 될 수 있다면 정수값을 만들어 내는 각종 연산자들도 당연히 쓰일 수 있다.

template class Foo<size_t N> { .... };

Foo<(a>b ? 5:3)> bar1;
Foo<(MAX>>3)> bar2;

그러니 위와 같은 상황에서는 수식 전체를 괄호로 싸야만 한다. 괄호가 단순히 같은 수식 안에서 연산 우선순위를 조절할 때만 쓰이는 게 아니라는 점이 흥미롭다. 수식 영역과 함수 및 템플릿 인자 영역을 구분할 때도 쓰인다.

std::vector<std::list<int>> vl;

요렇게 중첩되었던 템플릿 인자들이 한꺼번에 종결될 때 > 사이를 강제로 띄우지 않아도 되게 컴파일러의 동작 방식 지침이 달라진 때가 내 기억이 맞다면 C++03과 C++11사이였지 싶은데.. 정확하게는 모르겠다.

그 밖에 2[a]가 가능하다는 C/C++의 변태적인 특성상, 람다와 관련해서 또 변태 같은 중의성을 만들 수 있지는 않으려나 궁금한데, 너무 머리가 아파서 더 생각해 보지는 않으련다.
요즘 C++은 auto라든가 using, delete를 보면 =를 사용하는 새로운 문법이 여럿 생긴 것 같다.

2. 비표준이지만 표준처럼 쓰이는 함수

C언어 라이브러리에 있는 모든 함수들이 100% 표준이고 어느 플랫폼에서나 동일하게 사용 가능한 게 아니다.
본인은 평소에 Visual C++만 쓸 때는 이런 걸 전혀 의식하지 않고 지냈는데.. strlwr과 심지어 내 기억이 맞다면 strdup도 macOS에서는 지원되지 않는 걸 최근에 확인하고는 놀랐었다.
물론 저런 함수들이야 하는 일이 워낙 간단하니 3분 만에 직접 짤 수도 있다. 하지만 핵심은 저건 universal한 표준이 아니라는 것이다.

Visual C++도 세월이 흐를수록 '표준 준수'를 강조하는 쪽으로 라이브러리의 디자인이 바뀌다 보니, 관례적으로 제공되긴 했지만 엄밀히 말해 표준이 아닌 함수들에 대해서는 앞에 밑줄을 붙여서 구분하는 추세이다.
하긴 그러고 보니, Visual C++을 업그레이드 한 뒤에 기존 코드가 컴파일되지 않아서 수정하던 내역 중에도 멀쩡한 함수 앞에다가 _를 붙이는 게 많았다. 일례로, 이분 검색 함수는 bsearch가 당당히 표준으로 등재돼 있지만, 그에 상응하는 선형 검색 함수는 표준이 아니어서 그런지 _lfind이다.

3. 스택 메모리의 임의 할당

그러고 보니 비표준 함수 중에는.. malloc의 변종으로서 가변 길이(= 크기가 런타임 때 정해지는) 메모리를 heap이 아닌 무려 현재의 스택 메모리에서 얻어 오는 alloca이던가 malloca인가 하는 물건도 있었다. 옛날 16비트 Turbo C에만 있는 줄 알았는데 현재의 Visual C++에서도 지원은 하는가 보다. 물론 앞에 밑줄은 붙여서 말이다.

얘는 C에서 문법적으로 가능하지 않은 동적 배열을 heap이 아닌 스택 메모리에 구현해 준다. 메모리 할당 속도가 heap을 다루는 것보다 훨씬 더 빠르며, 함수 실행이나 scope이 끝날 때 해제도 자동으로 되어 memory leak 걱정을 할 필요 없으니 편리하다. 지금 실행 중인 함수의 stack frame을 조작하는 물건이니, 겉으로는 함수 호출 같지만 실제로는 컴파일러 인트린식 형태로 구현되지 싶다.

이렇게 생각하면 얘는 장점이 많아 보이지만.. 일단 할당 장소가 장소인 관계로 (1) 수 MB 이상급의 대용량 메모리를 할당할 수 없으며, (2) 할당 방식의 특성상 heap 메모리처럼 할당과 해제를 무순으로 임의로 자유자재로 할 수 없다. (3) C++ 언어의 보조를 받는 게 없기 때문에 해제와 C++ 객체 소멸을 한데 연계할 수도 없다.

이런 한계로 인해 스택에서의 동적 메모리 할당은 생각만치 그렇게 유용하지 않다. 본인도 지난 20여 년 동안 C/C++ 프로그래밍을 하면서 이걸 사용해 본 적이 전혀에 가깝게 없었다.
저 함수가 괜히 비표준이 아닌 셈이다. 마치 정수 기반 고정소수점과 비슷한 위상의 이단아인 것 같다. 다만, 그럼에도 불구하고 본인은 이런 상황은 어떨까 하는 생각을 해 보았다.

생성자에서 문자열을 인자로 받아들여 적절한 처리를 하는 기반 클래스가 있다.
이걸 상속받아 파생 클래스가 만들어졌는데, 얘는 자주 쓰이는 문자열 패턴을 손쉽게 생성하기 위해 여러 개의 문자열이나 숫자를 숫자를 인자로 받으며, 이로부터 단일 문자열을 생성하여 기반 클래스의 생성자에다가 전달한다. 즉, 이런 꼴이다.

Derived::Derived(string arg1, string arg2, int num):
 Base( prepareArgument(arg1, arg2, num) ) {}

예시를 보이기 위해 편의상 string이라는 자료형을 썼지만, 실제로 저기서 쓰이는 것은 const char * 같은 문자열 포인터이다.
즉, 나는 Derived의 생성자에서 char buf[128] 같은 스택 기반 지역변수 배열을 선언한 뒤, 거기에다 arg1, arg2, num의 정보를 담고 있는 문자열을 담고 그걸 Base의 생성자에다가 전달하고 싶으나.. 문법 구조상 그건 가능하지 않다. 기반 클래스는 파생 클래스의 생성자가 실행되기 전에 초기화가 완료돼야 하기 때문이다. 그러니 파생 클래스의 생성자 함수에서 확보해 놓은 스택 변수의 공간을 받을 방법도 존재하지 않는다.

이럴 때 prepareArgument(_alloca(len), arg1, arg2, num) 이런 식으로 static한 보조 함수를 만들면 굳이 힙 메모리 할당과 생성자· 소멸자가 뒤따르는 범용 string을 쓸 필요 없이 스택에다가 문자열을 담을 공간을 임시로 확보하여 소기의 목적을 달성할 수 있을 것으로 보인다.

4. 쓰레기값

'초기화되지 않은 변수, 쓰레기값'이라는 건 내가 아는 프로그래밍 언어들 중에는 C/C++에만 존재하는 개념이다. 물론 컴퓨터라는 기계에 본질적으로 존재하니까 C/C++에도 존재하는 것이지만 말이다.
이것 때문에 야기되는 버그의 황당함과 막장스러움은 뭐, 이루 말로 형용할 수 없다. 같은 소스 코드가 release 빌드와 debug 빌드의 실행 결과가 달라지는 건 애교 수준이다. 프로그램이 미묘하게 삑사리가 나고 있어서 몇 시간을 끙끙대며 디버깅을 했는데 원인이 고작 이것 때문인 일이 비일비재하다.

개인적으로는 부모 클래스의 멤버가 초기화되지 않았는데 그걸 자식 클래스에서도 초기화하지 않은 것, 처음에는 0 초기화가 보장되는 static 영역에 있던 오브젝트를 별 생각 없이 스택/힙으로 옮긴 것, 심지어 한 멤버를 초기화할 때 아직 초기화되지 않은 다른 멤버를 참조해서 망한 것이 기억에 남아 있다.

간단한 int 지역변수가 초기화되지 않은 건 컴파일러 차원에서 잡아 주지만 위와 같은 사항들, 복잡한 구조체의 멤버가 일부 초기화되지 않는 것, 스택이 아닌 힙에서 할당하는 동적 메모리가 돌아가는 사정은 컴파일러도 일일이 다 챙겨 주지 못하기 때문에 더 복잡한 정적 분석의 영역으로 가야 한다.

그런데 개인적으로 의문이 드는 건 초기화되지 않은 쓰레기값이란 건 어느 정도로 무질서하냐는 것이다. 무슨 수학적으로 균일한 난수 수준일 리는 없을 것이다.
그 쓰레기들의 값에 영향을 주는 것은 정확하게 무엇일까? (스택이냐 힙이냐에 따라 다르게 생각해야 할 듯) 컴파일 시점에서 결정되어서 한번 빌드된 프로그램은 동일한 동작 조건에서는 불변인 걸까? 혹은 운영체제에 따라 달라질 수 있을까?

마치 중간값 피벗 기반의 퀵 정렬이 최고 시간 복잡도가 나오게 공격하는 방법을 연구하는 것처럼 저것도 뭔가 컴퓨터공학적인 고찰이 필요한 의문인 것 같다.

5. 메모리 주소의 align 문제

"어..? 구조체의 크기가 왜 각 구조체 멤버들의 크기의 합보다 더 크지? 컴파일러의 버그인가?"
본인이 이렇게 크게 놀랐던 게 벌써 20여 년 전, 고딩 시절에 도스용 DJGPP를 갖고 놀던 때였다.
그때는 지금 같은 구글 검색도 없고 네이버 지식인도 없고.. 이런 시시콜콜한 이슈를 다루는 C언어 서적도 없었으니, 궁금하면 물어 볼 만한 곳이 PC 통신 프로그래밍 관련 동호회 게시판밖에 없었다.

메모리 취급에 매우 관대한 x86 물에서만 놀던 사람이라면 word align이라는 개념이 더욱 생소할 수밖에 없다. 더구나 그 경계에 맞지 않은 단위로 메모리 접근을 시도할 경우, CPU가 귀찮아서 예외까지 날린다면??
본인은 포팅이라는 걸 할 때 word align을 조심해야 한다는 것을 머리로는 들어서 알았지만 그 문제를 회사에서야 실제로 겪었다.

이제 네이티브 코드는 반드시 ARM64 기반으로 빌드해야 하니 해당 부분을 64비트로 다시 빌드했다. 그런데 동일한 엔진을 얹은 안드로이드 앱이 어떤 기기에서는 잘 돌아갔는데 다른 기기에서는 뻗었다.
죽은 지점이 어딘지는 stack dump를 통해 알아낼 수 있었지만 거기는 null pointer, buffer overflow 등 그 어떤 통상적인 메모리 문제가 발생할 여지도 없는 곳이었다.

알고 보니 거기는 파일 형태로 기록하는 조밀한 버퍼에다 wcsncpy( reinterpret_cast<wchar_t*>(buf+1), str, len) 이런 짓을 하고 있었으며, 타겟 포인터가 한눈에 보기에도 wchar_t의 크기 대비 word align이 되어 있지 않았다(buf 는 char* ㄲㄲㄲ).
그래서 wcsncpy를 memcpy로 교체함으로써 문제를 해결할 수 있었다. wchar_t는 long과 더불어 포팅을 어렵게 하는 주범이며, reinterpret_cast는 align과 관련된 잠재적 위험성을 발견하는 용도로도 쓰일 수 있다는 점을 알게 됐다.

복잡한 포인터 메모리 조작 코드에서 잠재적인 align 문제를 잡아내는 건 사람의 디버깅만으로는 한계가 있을 텐데, 정적 분석으로 가능한지 궁금하다. 그리고 반대로 레거시 코드를 돌리기 위해 컴파일러가 성능이 떨어지는 걸 감수하고라도 최소한 뻗지는 않게 align 보정 코드를 집어넣어 주는 옵션도 있을 텐데 그것도 궁금해진다.

6. 32비트 단위 문자열

C/C++에서 wchar_t 크기의 파편화로 인해 야기된 혼란과 원성이 워낙 장난이 아니었기 때문에 C++11에서는 아예 크기를 직접 명시하고 고정시킨 char16_t와 char32_t라는 자료형이 built-in으로 추가되었다. int는 32비트 시대에 크기가 변했고 long은 64비트 시대에 플랫폼별로 삐걱거리기 시작했다면, wchar_t는 유니코드와 함께 새로 등장하면서 저 지경이 된 셈이다.

개인적으로 인상적인 것은 char32_t는 U""라고 문자열 리터럴을 나타내는 접두사까지 언어 차원에서 새로 등장했다는 점이다. 드디어 확장 평면 문자도 취급하기 더 수월해지겠다.
그런데 그러면 Visual C++이라면 ""는 1바이트, L""는 2바이트, U""는 4바이트라고 자연스럽게 연결되는데, 처음부터 wchar_t가 4바이트였던 맥에서는 L과 U가 모두 4바이트이다. char16_t에 대응하는 2바이트 문자열은 리터럴로 표현하는 방법이 없나 궁금하다. 오히려 Objective C에서 사용하는 NSString의 @""가 2바이트 문자열 리터럴이다.

char32_t가 언어 차원에서 이렇게 지원되기 시작했는데 str*, wcs*처럼 32비트 문자열 버전에 대응하는 strlen, strcpy, sprintf 등도 있어야 하지 않나 싶다. C++이라면 char_traits 템플릿으로 땜빵할 수도 있겠지만 C에는 그런 게 없으니까..
그리고 템플릿이 없는 저쪽 동네 Java는 32비트 단위 문자열을 취급하는 string class 같은 것도 있어야 하지 않을까? 문자 하나도 확장 평면까지 감안해서 얄짤없이 int로 표기하는 건 직관적이지 못하고 불편하니 말이다.

7. 레퍼런스 사이트

C/C++은 마소의 C#, 애플의 Swift, 썬-오라클의 Java처럼 한 기업이 주도해서 개발하는 언어가 아니다. 그래서 C++ 라이브러리 레퍼런스 같은 걸 검색해도 딱 떨어지는 개발사의 홈페이지가 곧장 나오지는 않는다.
하지만 수 년 전부터 구글 검색에서 상위권으로 노출되고 있는 유명한 사이트는 아래의 딱 두 곳인 것 같다.

http://www.cplusplus.com
https://en.cppreference.com/w

얘들은 개인? 단체? 어디서 운영하는지 모르겠다. C++17, C++20 같은 최신 정보도 곧장 올라오는 걸 보니 유지보수도 활발히 되고 있고 만만하게 볼 퀄리티가 아니다.
마치 Doom 게임 관련 자료를 듬뿍 얻을 수 있는 위키 사이트가 doomwiki와 doom.fandom.com 요 두 계열로 나뉘듯이 말이다.

Posted by 사무엘

2019/11/13 08:33 2019/11/13 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1683

1. 스마트 포인터의 필요성

C/C++에서 포인터로 참조하는 동적 메모리가 안전하게 관리되기 위해서는.. 가장 간단하게는 포인터의 생명 주기와 그 포인터가 가리키는 메모리 실체의 생명 주기가 동일하게 유지돼야 할 것이다. 어느 한쪽이 소멸되면 다른 쪽도 소멸돼야 한다. C++에서는 이 정도 절차는 포인터를 클래스로 감싸고 그 클래스의 생성자와 소멸자 함수를 구현함으로써 자동화할 수 있다.

하지만 이것만으로 문제가 다 해결되는 건 아니다. 어떤 메모리에 대한 포인터의 ownership이 더 깔끔하게 관리되고 통제돼야 한다. 멀쩡한 주소값이 딴 걸로 바뀌어서 원래 가리키던 메모리로 접근 불가능해지거나(leak..), 이미 해제된 메모리를 계속 가리키고 있다가 사고가 나는 일도 없어야 한다.

그런 일을 예방하려면 여러 포인터가 동일 메모리를 참조하는 것을 완전히 금지하고 막든가, 아니면 reference count 같은 걸 따로 둬서 그런 상황에 대비를 해야 한다. 실행시켰을 때 뻑이 날 만한 짓은 아예 컴파일이 되지 않고 거부되게 해야 한다.
이런 메모리 관리를 자동으로 해 주는 클래스가 표준 C++ 라이브러리에도 물론 구현돼 있으며, 크게 두 가지 관점에서 존재한다.

  • 배열 지향: POD 또는 비교적 단순한 오브젝트들의 동적 배열로, 원소들의 순회, 추가· 삭제와 전체 버퍼 재할당 같은 동작에 최적화돼 있다. 원소 전체 개수와 메모리 할당량 정보가 별도로 들어 있으며, 문자열 클래스도 어찌 보면 배열의 특수한 형태라고 간주할 수 있다. [] 연산자가 오버로딩 돼 있다.
  • 오브젝트 지향: 단일 오브젝트 중심으로 메모리 할당 크기보다는 소유자(ownership) 관리에 더 최적화돼 있다. 그래서 구현 방식에 따라서는 원소 개수 대신 레퍼런스 카운트 정보가 있곤 한다. 담고 있는 타입 형태로 곧장 활용 가능하게 하기 위해, ->와 * 같은 연산자가 반드시 오버로딩 돼 있다.

C/C++은 배열과 포인터의 구분이 애매하니 helper class는 각 분야에 특화된 형태로 따로 구현되었다는 것을 알 수 있다.
배열 버전이야 std::vector라는 유명한 클래스가 있고, 오브젝트를 담당하는 물건을 우리는 smart pointer라는 이름으로 오랫동안 불러 왔다.

Windows 진영에서도 ATL 내지 WTL 라이브러리에는 일반 포인터뿐만 아니라 COM 인터페이스를 감싸서 소멸자에서 Release를 해 주고, 대입 연산자 및 복사 생성자에서 AddRef 따위 처리를 해 주는 간단한 클래스가 물론 있었다.
소멸자는 예외 처리가 섞여 있을 때 더욱 빛을 발한다. 함수의 실행이 종료되는 경로가 여럿 존재하게 됐을 때 goto문을 안 쓰고도 메모리 단속이 꼼꼼하게 되는 것을 언어와 컴파일러 차원에서 보장해 주기 때문이다. 그리고 이 정도 물건은 C++ 좀 다루는 프로그래머라면 아무라도 생각해 내고 구현할 수 있다.

2. 초창기에 도입됐던 auto_ptr과 그 한계

C++은 이런 스마트 포인터도 표준화하려 했으며, 그 결과로 auto_ptr이라는 클래스가 C++98 때부터 도입됐다. 선언된 헤더는 #include <memory>이다.
그러나 auto_ptr는 오늘날의 최신 C++의 관점에서 봤을 때는 썩 좋지 못한 설계 형태로 인해 deprecate됐다. 이미 이걸 사용해서 작성돼 버린 레거시 코드를 실행하는 것 외의 용도로는 사용이 더 권장되거나 지원되지 않게 되었다.

그 대신, C++11부터는 용도를 세분화한 unique_ptr, shared_ptr, weak_ptr이라는 대체제가 등장했다. 이거 마치 C-style cast와 C++ *_cast 4종류 형변환의 관계처럼 보이지 않는가? =_=;;

auto_ptr은 한 메모리를 오직 한 포인터만이 참조하도록 하고 포인터가 사라질 때 소멸자도 호출해 주는 최소한의 기본 조치는 잘 해 줬었다. auto_ptr<T> ptr(new T(arg1, ...)) 같은 꼴로 선언해서 사용하면 됐다. 하지만...

(1) 단일 포인터와 배열의 구분이 없었다.
물론 스마트 포인터는 전문적인 배열 컨테이너 클래스와는 용도가 다르니, 원소의 삽입· 삭제나 원소 개수 관리, 메모리 재할당 처리까지 할 필요는 없다.

하지만 클래스의 소멸자에서 호출해 주는 clean-up을 별도의 템플릿 인자로 추상화하지는 않았고 그냥 delete ptr로 고정해 놓았기 때문에.. 당장 delete와 delete[]조차도 구분할 수 없어서 번거로웠다. 다시 말해 auto_ptr<T> ptr(new T[100]) 이런 식으로 써먹을 수는 없다.

(2) 포인터의 ownership을 관리하는 것까지는 좋으나.. 그게 복사 생성자 내지 대입 연산자에서 우항 피연산자를 변조하는 꽤 기괴한 형태로 구현돼 있었다.
무슨 말이냐 하면.. auto_ptr<T> a(ptr), b에서 b=a 또는 b(a)라고 써 주면.. b는 a가 가리키는 값으로 바뀜과 동시에 a가 가리키는 값은 NULL로 바뀌었다. 즉, 포인터와 메모리의 일대일 관계를 유지시키기 위해, 소유권은 언제나 복사되는 게 아니라 이동되게 한 것이다.

그렇게 구현한 심정은 이해가 되지만, 대입 연산에서 A=B라고 하면 A만 변경되어야지, B가 바뀌는 건 좀 납득이 어렵다.
복사 생성자라는 것도 형태가 T::T(const T&)이지, T::T(T&)는 아니다. 차라리 임시 객체만 받는 R-value 이동 전용 생성자라면 T::T(T&&)이어서 우항의 변조가 허용되지만, 복사 생성자는 그런 용도가 아니다.

(3) 위와 같은 특성이랄지 문제로 인해.. auto_ptr은 call-by-value 형태로 함수의 인자나 리턴값으로 그대로 전달했다간 큰일 났다.
메모리의 소유권이 호출된 함수의 인자로 완전히 옮겨져 버리고, 그 함수가 끝날 때 그 메모리는 auto_ptr의 소멸자에 의해 해제돼 버리기 때문이다. 이 문제를 컴파일러 차원에서 잡아낼 수 없다. (뭐, 이미 free된 메모리를 이중으로 해제시키는 사고는 나지 않는다. 깔끔한 null pointer 접근 에러가 날 뿐.)

auto_ptr을 함수 인자로 전달하려면 그냥 call-by-reference로 하든가, 아니면 그 원래의 T* raw 포인터 형태로 전해야 했다.
아니, 함수 인자뿐만 아니라 값을 그대로 함수의 리턴값으로 전할 때, 혹은 vector 및 list 같은 컨테이너에다 집어넣을 때 등.. 임시 객체가 발생할 만한 모든 상황에서 동일한 문제가 발생하게 된다.

이게 제일 치명적이고 심각한 문제이다. 여러 함수를 드나들고 컨테이너에다 집어넣는 것도 raw pointer와 다를 바 없이 가볍게 되라고 smart pointer를 만들었는데 그러지 못한다면.. 이걸 만든 의미가 없다. 그러면 한 함수 안에서 달랑 소멸자 호출만 자동화해 주는 것 말고는 쓸모가 없다.
또한, 매번 call-by-reference로 전하는 건 엄밀히 말해 포인터의 포인터.. 즉, 포인터를 정수가 아니라 구조체 같은 덩치 큰 물건으로 취급하는 거나 마찬가지이고..

이런 이유로 인해 auto_ptr은 좋은 취지로 도입됐음에도 불구하고, 현재는 이런 게 있었다는 것만 알고 최신 C++에서는 잊어버려야 할 물건이 됐다.
(1) C 라이브러리 함수라든가(gets...) (2) C++ 키워드뿐만 아니라(export) (3) C++ 라이브러리 클래스 중에서도 흑역사가 생긴 셈이다.

auto_ptr이 무슨 보안상의 결함이 있다거나 성능 오버헤드가 크다거나 한 건 아니다. 21세기 이전에는 C++에 R-value 참조자 같은 문법이 없었으니 복사 생성자에다가 move 기능을 집어넣을 수밖에 없었다. 나중에 C++에 언어 차원에서 smart pointer의 불편을 해소해 주는 기능이 추가된 뒤에도 이미 만들어진 클래스의 문법이나 동작을 변경할 수는 없으니 새 클래스를 따로 만들게 된 것일 뿐이다.

3. unique_ptr

auto_ptr의 가장 직접적인 대체제는 unique_ptr이다.
얘는 최신 C++에서 새로 추가된 문법을 활용하여 단일 개체와 배열 개체를 구분할 수 있다. unique_ptr<T>와 unique_ptr<T []>로 말이다. 신기하다..;;
그리고 템플릿 가변 인자 문법을 이용하여 new를 생략하고 std::make_unique<T>(arg1, arg2..) 이렇게 객체를 생성할 수도 있다. 얘는 C++14에서야 도입된 더 새로운 물건이다.

unique_ptr은.. 말 많고 탈 많던 복사 생성자와 대입 연산자가 막혀 있다. 함수에 날것 형태로 전달하거나 컨테이너에 집어넣는 등의 시도를 하면.. 그냥 컴파일 에러가 나게 된다. 그래서 안전하다.
이전의 auto_ptr이 하던 것처럼 소유권을 옮기는 것은 R-value 이동 생성자라든가 std::move 같은 다른 방법으로 하면 된다.

어떤 클래스에 대해서 복사 생성자와 대입 연산자가 구현돼 있지 않으면 컴파일러가 디폴트, trivial 구현을 자동 생성하는 편이다. 각 멤버들에 대한 memcpy 신공 내지 대입 연산자 호출처럼 해야 할 일이 비교적 직관적으로 뻔히 유추 가능하기 때문이다. 하지만 클래스에 따라서는 그런 오지랖이나 유도리가 바람직하지 않으며 이를 금지해야 할 때가 있다. 인스턴스가 단 하나만 존재해야 하는 singleton 클래스, 또는 저렇게 반드시 1핸들, 1리소스 원칙을 유지해야 하는 클래스를 구현할 때 말이다.

그걸 금지하는 가장 전형적이고 전통적인 테크닉은 해당 함수를 private으로 선언해 버리는 것이 있다. (정의는 당연히 하지 말고)
하지만 이것도 friend 함수에서는 안 통하는 한계가 있기 때문에 최신 C++에서는 액세스 등급과 별개로 상속 받았거나 디폴트 구현된 멤버 함수의 사용을 그냥 무조건적으로 금지해 버리는.. = delete라는 문법이 추가되었다. 순수 가상 함수를 나타내는 = 0처럼 말이다! unique_ptr은 이 문법을 사용하고 있다.

그럼 unique_ptr은 컨테이너에 집어넣는 게 전혀 불가능한가 하면.. 그렇지 않다.

vector<unique_ptr<T> > lc;
lc.push_back( unique_ptr<T>(new T) );

처럼 push_back이나 insert에다가 T에 속하는 변수를 줄 게 아니라 저렇게 애초부터 R-value 임시 객체를 주면 된다.
그러면 임시 객체의 ownership이 컨테이너 안으로 자연스럽게 옮겨지고, 컨테이너 안의 unique_ptr만이 유일하게 T를 가리키고 있게 된다.

얘는 auto_ptr보다 상황이 훨씬 더 나아졌고 이제 좀 쓸 만한 smart pointer가 된 것 같다.
사실, 작명 센스조차도.. auto는 도대체 뭘 자동으로 처리해 준다는 건지 좀 막연한 구석이 있었다. 그게 unique/shared로 바뀐 것은 마치 '인공지능'이라는 막연한 용어가 AI 암흑기를 거친 후에 분야별로 더 구체적인 기계학습/패턴인식 같은 말로 바뀐 것과 비슷하게 들리기도 한다. ㅎㅎ

4. shared_ptr와 weak_ptr

그럼 다음으로, shared_ptr을 살펴보자.
얘는 마치 COM의 IUnknown 인터페이스처럼 reference counting을 통해 다수의 포인터가 한 메모리를 참조하는 것에 대한 대비가 돼 있다. 그래서 unique_ptr과 달리, 대입이나 복사를 자유롭게 할 수 있다.

(1) 날포인터는 그냥 대책 없이 허용하기 때문에 ownership 문제가 발생하고.. 아까 (2) auto_ptr은 무조건 ownership을 옮겨 버리고, (3) unique_ptr은 깔끔하게 금지하는데 (4) 얘는 참조 횟수를 관리하면서 허용한다는 차이가 있다. 소멸자는 가리키는 놈의 reference count를 1 감소시켜서 그게 0이 됐을 때만 실제로 메모리를 해제한다.

그래서 shared_ptr은 크기 오버헤드가 좀 있다.
unique_ptr은 일반 포인터 하나와 동일한 크기이고 기술적으로 machine word 하나와 다를 바 없는 반면, shared_ptr은 reference count 데이터를 가리키는 포인터를 추가로 갖고 있다. 일반 포인터 두 개 크기를 차지한다.

이는 static_cast보다 dynamic_cast가 오버헤드가 더 큰 것과 비슷한 모습 같다. 그리고 멤버 포인터가 다중 상속 하에서의 this 오프셋 보정 때문에 추가 정보를 갖고 있다면, 얘는 ownership 관리 때문에 추가 정보를 갖고 있다는 점이 비교된다.

끝으로, weak_ptr이라고, shared_ptr와 같이 쓰이는 포인터도 있다. 얘는 이름에서 유추할 수 있듯이 reference count를 건드리지 않는다. 순환 참조 문제를 예방하려면 A에서 B를 참조한 뒤에 B에서 또 A를 참조할 때는 레퍼런스 카운트를 건드리지 않아야 하기 때문이다.
순환 참조는 단순히 A→B→A뿐만 아니라 A→B→C→A 같은 더 복잡한 형태로도 발생하며, 일단 발생한 것을 감지하기란 몹시 난감하다. 그러니 weak_ptr이라는 개념이 반드시 필요하다.

그럼 reference count를 건드리지 않고 shared_ptr에 접근하려면 그냥 raw 날포인터를 얻어서 간단히 써도 될 텐데 굳이 weak_ptr이 따로 존재하는 이유는? 비록 ref count를 건드리지 않더라도 날포인터보다는 더 안전한 놈이 필요하기 때문이다.

weak_ptr은 다른 shared_ptr을 가리킬 수 있다.
shared_ptr은 자신과 동일한 shared_ptr로부터 참조받은 횟수와, weak_ptr로부터 참조받은 횟수를 따로 관리한다. 그렇기 때문에 shared_ptr은 이렇게 2개의 숫자로 이뤄진 레퍼런스 데이터도 따로 동적 생성해서 포인터로 가리키면서 동작한다.

weak_ptr이 가리키는 객체에 실제로 접근하려면 weak_ptr::lock()을 호출해서 weak로부터 shared_ptr을 잠시 생성해야 한다. 이 동안은 shared_ptr의 실제 레퍼런스 카운트가 증가하기 때문에 한쪽의 스마트포인터가 소멸되더라도 dangling pointer 현상이 발생하지 않는다. 이게 weak_ptr이 날포인터와의 차이점이다.

하지만 이 lock이 언제나 성공하지는 않을 수 있다. lock이 걸리지 않았을 때 weak가 shared를 가리키고 있는 건 shared의 실제 참조 횟수에 아무 영향을 끼치지 않는다. 그렇기 때문에 이 동안은 shared가 가리키는 객체는 순환 참조 없이 소멸이 가능하다.

그럼 weak_ptr 따위 안 쓰고, 그냥 shared_ptr를 놔두고 평소엔 null을 저장했다가 lock을 걸 상황에만 원래 포인터 값을 참조하는 것과 무슨 차이가 있느냐는 의문이 생길 수 있는데.. weak_*는 그것보다 더 안전하다. 자신이 가리키는 객체가 소멸되었는지의 여부를 expired() 함수로 간편하게 알 수 있다. 레퍼런스 카운트를 이중으로 관리하고 메모리도 이중으로 관리하는 성능 오버헤드를 괜히 감수한 게 아니다.

weak_ptr은 shared_*와 마찬가지로 크기가 포인터 2개이다. 단, 사용법이 unique_*나 shared_*와는 좀 다르다.
저 둘은 ->나 * 연산자를 이용해서 자신이 가리키는 객체에 바로 접근할 수 있는 반면, weak_*는 그렇지 않다. 그리고 실제 객체를 생성하는 make_unique와 make_shared 함수는 있는 반면, make_weak는 없다. weak_*는 그 정의상 기존 shared_*로부터 유도되어 생성되는 걸 가정하기 때문이다.

이상이다. 그냥 생성자와 소멸자를 적절히 구현해 주고 ->와 *만 오버로딩 해 주면 끝일 것 같은 smart pointer도 깊게 들어가면 내막이 생각보다 더 복잡하다는 것을 알 수 있다.
Rust 언어는 garbage collector 기반이 아니면서 더 독특한 방식으로 메모리 소유권을 관리한다던데 그 내막이 어떠했던지가 다시 궁금해진다.

5. 여담

(1) = delete는 다시 봐도 참신하기 그지없다. delete라는 키워드가 연산자 말고 이런 용도로도 활용되는 날이 오더니!
배열 첨자 연산자이던 []와 구조체 참조 연산자이던 ->가 람다 선언에서 의미가 완전히 확장된 것만큼이나 참신하다.
하긴, 옛날에 템플릿이 처음 등장했을 때.. 그저 비교 연산자일 뿐이었던 <와 >가 완전히 새로운 여닫는 형태로 사용되기 시작한 것도 정말 충격적인 변화였을 것이다.

(2) 글쎄, 멤버 함수의 접근을 금지하는 방법이 저렇게 도입됐는데, 어떤 클래스에 대해서 Java의 final이나 C#의 sealed처럼 상속이 더 되지 않게 하는 옵션은 C++에 도입되지 않으려나 모르겠다. C++은 타 언어에 없는 protected, private 상속이 존재하지만 상속 자체를 금지하는 옵션은 없어서 말이다.

특히 내부 구조가 아주 간단하고 가상 함수가 존재하지 않는 것, 특히 소멸자가 가상 함수 형태로 별도로 선언되지 않은 클래스는 상속을 해도 어차피 polymorphism을 제대로 살릴 수 없다. 그냥 단순 기능 확장에만 의미를 둬야 할 것이다.
Java는 모든 함수가 기본적으로 가상 함수일 정도로 유연한데도 이와 별개로 상속을 금지하는 옵션이 있는데.. 그보다 더 경직된 언어인 C++은 의외로 그런 기능이 없다.

(3) C/C++의 사고방식에 익숙한 프로그래머라면 포인터란 곧 메모리 주소이고, 본질적으로 machine word와 동일한 크기의 부호 없는 정수 하나일 뿐이라는 편견 아닌 편견을 갖고 있다.
하지만 객체지향이라든가 함수형 등 프로그래밍 언어 이론을 조금이라도 제대로 구현하려면 숫자 하나만으로 모든 것을 표현하기엔 부족한 포인터가 얼마든지 등장하게 된다.

앞서 다뤘던 shared_ptr이라든가 다중 상속을 지원하는 멤버 함수 포인터..
그리고 자기를 감싸는 문맥 정보가 담긴 클래스 객체 포인터라든가 람다 함수 포인터 말이다.
C++은 전자를 기본 지원하지 않기 때문에 모든 클래스들이 Java 용어로 치면 개념적으로 static class인 거나 마찬가지이다.
그리고 후자를 기본 지원하지 않기 때문에 람다는 캡처가 없는 놈만 기존 함수 포인터에다 담을 수 있다.

그런 것들이 내부적으로 어떻게 구현되고 구현하는 시공간 비용이 어찌 되는지를 프로그래머라면 한 번쯤 생각할 필요가 있어 보인다.

(4) C++에서 class T; struct V; 처럼 이름만 전방 선언된 incomplete type에 대해서는 제일 단순한 직통 포인터, 그리고 무리수가 좀 들어간 멤버 포인터 정도만 선언할 수 있다. T나 V의 실체를 모르니 이런 타입의 개체를 생성하거나, 포인터를 실제로 참조해서 뭔가를 할 수는 없다.
그런데 이런 불완전한 타입을 가리키는 포인터를 상대로 delete는 가능할까? 난 이런 상황에 대해 지금까지 한 번도 생각해 본 적이 없었다.

sizeof(T)의 값을 모르더라도 포인터가 가리키는 heap 메모리 블록을 free하는 것은 얼마든지 가능하다. 애초에 malloc/void가 취급하는 것도 아무런 타입 정보가 없는 void*이니 말이다.
그러니 operator delete(ptr)은 할 수 있지만, 해당 타입에 대한 소멸자 함수는 호출되지 못한다.

컴파일러는 이런 코드에 대해서 경고를 띄우는 편이다. Visual C++의 경우 C4510이며, delete뿐만 아니라 delete[]에 대해서도 동일한 정책이 적용된다.

Posted by 사무엘

2019/10/09 08:35 2019/10/09 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1671

그리고.. 이튿날 아침에는 계속해서 봉화로 이동하기 시작했다. 새벽에는 계절이 바뀌기라도 했나 싶을 정도로 공기가 차가워져 있었다.

사용자 삽입 이미지

주변엔 계속해서 이런 풍경이 펼쳐졌다.
그러고 보니 옛날 도로는 가드레일 말고 저렇게 노란 경계석이 쓰였던 것 같은데 요즘은 어느 샌가 보기 힘든 풍경이 돼 있다.

사용자 삽입 이미지

중간에 마을 어귀를 흐르는 요런 맑은 개울을 발견해서 차를 세우고 물놀이를 했다.
이럴 때 햇볕이 쨍쨍하고 날씨가 더 더웠으면 물놀이의 효과가 더 커졌겠지만, 폭염과 가뭄 때문에 개울이 말라 버리는 것보다야 이렇게라도 물에 들어가는 게 훨씬 더 낫다. 비 덕분에 여기 모든 개천· 개울들은 물이 콸콸 세차게 흐르고 있어서 참 보기 좋았다.
그렇잖아도 어제 땀을 많이 흘려서 몸이 온통 끈적거리는 상태였는데 싹 개운해졌다.

사용자 삽입 이미지사용자 삽입 이미지

그리고.. 일월산 자생화 공원이라는 곳을 발견해서 들렀다가 갔다. 주변에 나밖에 없는 오지에서 자연을 즐긴다는 건 참 멋진 일이다. 지구가 금성 화성과 달리 초록별이라는 사실에 경이로움과 고마움을 느끼게 된다.

사용자 삽입 이미지

영양에서 봉화로 가려면 역시 산을 하나 넘어야 하더라. 길은 어느 샌가 꼬불꼬불한 산길로 바뀌었다.
터널 안에 들어갈 때는 일반적으로는 헤드라이트를 켜는 게 맞는데, 현실에서는 '끄시오'라고 안내된 표지판도 많이 보인다. 화장실에서 휴지는 변기에 "넣으시오/넣지 마시오"만큼이나 사람을 헷갈리게 만드는 사항인 것 같다.

저 영양 터널을 지난 다음에는 행정구역이 봉화로 바뀌고, 봉화 터널과 어느 공군 부대가 뒤이어 등장했다. 그리고 오르막이 끝나고 내리막이 시작됐다.

6. 봉화군 탐험기

본인은 이번 여행의 마지막 일정으로, 봉화에서 영동선 양원 역을 답사하고 봉화 시가지로 돌아갔다.

사용자 삽입 이미지
사용자 삽입 이미지사용자 삽입 이미지

상· 하행 합쳐도 차가 몇 분에 한 대 다닐까말까인 이 꼬불꼬불 산길을 떠나고 싶지 않았다. 그래서 수시로 정차하고 사진 찍고 시동 끄고 사색에 잠기곤 했다.

사용자 삽입 이미지

산을 하나 넘어서 내려오니 또 강이 펼쳐졌다. 이 강은 온통 흙탕물이었다.

사용자 삽입 이미지사용자 삽입 이미지

그리고 영동선 철길 위를 지나갔다. 참 공교롭게도 본인이 아래를 내려다보고 있는 타이밍에 맞춰서 여객도 아니고 화물 열차가 하나 지나갔다. 얼마나 무거운 짐을 끄는지, 전기 기관차 중련에다가 보조 중형 디젤 기관차까지 편성한 상태였다.

사용자 삽입 이미지

드디어 양원 역이 자리잡고 있다는 어느 마을 어귀에 도착했다. 마을 앞에도 맑은 물이 졸졸 흐르고 있었다.
양원 역은 행정구역상 봉화이지만 동쪽 맨 끝이기 때문에 거의 울진 근처이며, 가는 길에 울진의 서쪽 끝을 경유하기도 했다. 봉화 시가지와는 수십 km 이상 떨어져 있었다.

사용자 삽입 이미지

그런데 마을에 도착했다고 끝이 아니었다. 양원 역으로 가려면 거기서도 산을 하나 타넘어야 했다~!
엔진 회전수 4000rpm을 고속도로에서 고속으로 밟을 때도 찍고, 여기서 2단 엔진 브레이크로도 찍었다. 1단 말고 2단으로 말이다.
그렇게 산을 넘은 뒤에도 저 길로 들어가야 했는데.. 차가 지나갈 수는 있지만 거기서 주민에게 민폐를 끼치지 않고 딱히 차를 세울 만한 공간이 없었다. 그래서 차는 이 부근에서 세우고 여기서부터 몇백 m 남짓은 걸어가야 했다.

사용자 삽입 이미지사용자 삽입 이미지

지도를 보니 이 강은 낙동강 상류라고 한다. 물이 콸콸 세차게 흐르고 있었다. 저 교각은 무슨 옛날에 있었던 다리의 흔적 같다.
양원 역이 이 정도로 답 없는 오지에 있는지는 미처 몰랐다. 저기를 건너간 다음에야..

사용자 삽입 이미지

드디어 얘를 실물로 보게 되었다.
예전에 태백선· 함백선 부근에서 조동 역과 함백 역을 보던 느낌이었다.

사용자 삽입 이미지

양원 역은 전국에서 유일하게.. 지역 주민들이 교통이 너무 불편해서 현기증이 나니 제발 열차 좀 정차시켜 달라고 아우성 치고 정부에 청원 넣고, 아무 국고 지원 없이 스스로 돈을 모아서 역 건물을 지어서 승인 받은 역이다. 진정한 의미의 민자역사인 셈이다. 단지, 자본주의 논리가 아니라 지역 주민 복지를 위한 민자역사인 것이고.. 그게 지금으로부터 무려 30년 전의 일이다.
전날 옥천에서 정 지용 시인 관련 안내문에서도 1988이라는 숫자를 봤는데, 양원 역의 개업 시기도 1988년 4월이라니 우연 치고는 절묘하다. (지용회: 1988년 3월)

얼마나 한이 서렸으면 양원 역에 첫 열차가 정차하던 날 사람도 감격하고 산과 강도 감격했댄다.
옛날엔 열차가 여기 마을을 통과할 때 짐보따리부터 미리 던져 놓은 뒤 더 먼 승부 역에서 여기까지 걸어서 돌아와서 그 짐을 챙겼다니..

지금으로서는 상상하기 어렵겠지만, 옛날에는 열차에 화장실이 비산식이었고(오물이 선로로 그대로...; ), 주행 중일 때 차량의 출입문이나 창문을 수동으로 조작할 수 있었다.
그럼 근성 있는 사람이라면 짐만 미리 던져 놓는 게 아니라, 자기 자신까지 훌쩍 뛰어내릴 법도 해 보이는데.. 그러기에는 아무리 젊고 민첩한 사람이라도 좀 위험했을 것 같다.

사용자 삽입 이미지사용자 삽입 이미지

영락없이 컨테이너 가건물 같은 규모이지만 컨테이너가 아니다. 시멘트를 얹어서 정식으로 지은 건축물이다.

사용자 삽입 이미지사용자 삽입 이미지

이 궁서체 안내판까지도 주민들이 직접 만든 거라고 한다.
현재는 여기가 유명세를 타면서 정규 여객열차뿐만 아니라 V-train(백두대간 협곡)과 O-train(중부내륙 순환)이라는 관광 열차도 정차하는 곳이 되었다. 그러니 역 건물과 안내판 말고 저 승강장은 코레일에서 만들었다는 티가 난다.
순환선이기 때문에 O이고, 그리고 계곡 모양을 형상화해서 V라니.. 코레일 수뇌부에서 머리 좀 쓴 것 같다. 이름을 참 기발하게 지었다.

사진을 찍지는 못했지만 마침 저기에 열차가 정차하는 것을 봤다.
이번 여행 동안 경부선과 영동선을 지나는 열차를 종종 목격했는데, 모두 전기 기관차 기반이었다. 디젤 기관차는 전혀 보지 못했다.

사용자 삽입 이미지사용자 삽입 이미지
사용자 삽입 이미지

서쪽의 봉화 시가지로 가는 길에 또 절경을 발견하여 풍경 사진을 찍었다.

사용자 삽입 이미지

그리고 오후가 돼서야 봉화 시가지에 도착했다. 이 강은 낙동강의 지류인 내성천이다.
여기서 식사를 하고 보급을 받는 것으로 2일간의 여행에 종지부를 찍었다.
올해도 어김없이 정말 즐거운 시간을 보냈다. 앞으로는 3년 주기로 남해안, 서해안, 동해안을 한 번씩 가는 것으로 어렴풋이 계획을 잡았다.

Posted by 사무엘

2019/09/13 08:33 2019/09/13 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1661

Windows용 GUI 프로그램은 가시적인 GUI 구성요소인 윈도우/창이라는 걸 생성해서 화면에 띄울 수 있다.
윈도우는 메뉴, 제목 표시줄, 테두리 같은 '껍데기' non-client 영역과, 그 내부에 프로그램이 재량껏 내용을 표시하는 '알맹이' client 영역으로 나뉜다. 윈도우에다 스타일을 무엇을 주느냐에 따라 non-client 영역의 구성요소와 차지 면적이 달라진다. 똑같이 테두리를 주더라도 크기 조절이 가능한 창은 그렇지 않은 창보다 테두리가 더 두껍게 그려지곤 한다.

GetWindowRect 함수는 어떤 창이 화면에서 문자 그대로 차지하는 스크린 좌표(non-client와 client 모두 포함)를 되돌리며, GetClientRect는 창 내부의 클라이언트 영역의 크기를 되돌린다.
후자의 경우 RECT를 되돌리긴 하지만 left와 top 멤버의 값은 언제나 0으로 설정된다. 그러니 얘는 진짜 RECT라기보다는 SIZE의 성격에 더 가깝다. 하지만 창의 client 영역을 원점(0,0) 기준으로 DC에다 그림을 그릴 일이 많으며 GDI 함수 중에도 RECT를 받는 놈이 있다. 그렇기 때문에 저런 형태의 RECT를 받는 건 여러 모로 유용하다.

어떤 창의 클라이언트 영역이 자기 창의 non-client 영역을 기준으로 얼마나 떨어져 있는지, 그리고 원점 또는 전체 스크린 좌표 기준으로 어디에서 시작하는지를 얻기란 쉽지 않다.
또한, 어떤 child 윈도우가 부모 창의 클라이언트 영역 좌표를 기준으로 어디쯤에 있는지를 얻는 것도 바로 가능하지 않다(예: 대화상자의 어떤 컨트롤이 대화상자를 기준으로 어느 위치에 있는가?). 기준이 되는 창들을 모두 스크린 좌표로 얻어 온 뒤에 수동으로 좌표 오프셋 보정을 해야 답을 구할 수 있다.

뭐 이런 식이다.
크기와 스타일이 동일한 창에 대해서 전체 영역 대비 클라이언트 영역이 위치와 크기가 어떻게 결정될 것인지는 얼추 고정적으로 예상 가능하다.
그런데 이렇게 직관적이고 명백해 보이는 영역에도 뭔가 골치 아프고 지저분한 가상화, 보정, 샌드박스라는 게 존재한다는 게 믿어지시는가?

이 문제는 지금으로부터 10수 년 전, Windows Vista가 등장하면서 시작됐다.
과거의 XP가 단조로운 고전 테마를 탈피하여 non-client 영역과 각종 표준 컨트롤에 임의의 테마 비트맵을 도입했는데, Vista/7은 더 나아가서 그래픽 가속 기능의 도움으로 프로그램 창 주변에 짙은 그림자를 그리고, 테두리에는 뭔가 청명한 느낌이 나는 반투명(?) 유리 재질을 구현했다.

그래서 법적으로(?) 똑같은 크기의 창이라도 창이 화면에다 실제로 1픽셀이라도 그림을 그리는 영역이 더 커졌으며, 테두리의 두께도 더 두꺼워졌다.
특히, 크기 조절이 되지 않는 창이라도 "크기 조절이 되는 창과 동급으로" 테두리가 두꺼워졌다.
이로 인해 window rect라는 개념을 해석하는 방식에도 변화가 불가피해졌다.

그럼 골치 아픈 설명 대신 예를 들도록 하겠다.
대화상자를 생성하여 (1, 1) 위치에다 300, 200픽셀 크기로 배치(SetWindowPos 호출)하는 프로그램을 돌려 보았다.
이걸 Visual C++ 2010 이하, 또는 2012 이상이라도 Windows XP 호환 툴체인으로 빌드하면 실행 결과가 다음과 같이 나온다.

사용자 삽입 이미지

그러나 동일한 코드를 Visual C++ 2012 이상의 정규 툴체인으로 빌드하면 실행 결과가 다음과 같다. 둘의 차이가 뭘까?

사용자 삽입 이미지

옛날 방식으로 빌드하면.. window rect를 계산할 때 Windows Vista/7의 Aero가 추가적으로 그려 주는 껍데기 공간이 영역에 포함되지 않는다. 그래서 window rect가 음수가 아니라 분명 (1, 1)임에도 불구하고 창의 좌측 상단이 좀 짤린다.
이 창이 실제로 차지하는 영역을 얻으려면 DwmGetWindowAttribute(m_hWnd, DWMWA_EXTENDED_FRAME_BOUNDS, &rc, sizeof(rc))를 호출해 줘야 한다. 보다시피 실제 영역은 (-4, -4)에서부터 시작하면서 5픽셀이나 더 크다는 걸 알 수 있다.

그러나 최신 방식으로 빌드하면 창의 모든 영역이 window rect에 포함된다. 이 때문에 창의 크기가 더 작아지며, 클라이언트 영역의 크기도 10픽셀씩이나 차이가 나게 된다. 그 대신 얘는 GetWindowRect와 DwmGetWindowAttribute의 결과가 서로 일치한다.
두 프로그램의 실행 결과를 한 화면에 포개 놓으면 다음과 같다.

사용자 삽입 이미지

이런 차이를 만들어 내는 것은 바로.. 실행 파일의 PE 헤더에 기록되어 있는 운영체제/서브시스템의 버전이다. 링커에서 /SUBSYSTEM:WINDOWS,6.0 옵션으로 지정 가능하다.
Visual C++ 2010의 컴파일러는 5.1 (XP)이 최저값이기 때문에 필요한 경우 6.0 (Vista)으로 올려서 지정할 수 있다. 그러나 2012 이상 후대의 컴파일러는 선택의 여지 없이 6.0이 최소값이며 더 낮은 값으로 지정이 되지 않는다. 툴체인 자체를 더 낮은 것으로 골라야 한다.

의외로 매니페스트 XML이 아니라는 게 신기하다. 공용 컨트롤 6.0 사용 여부, 고해상도 DPI 인식 여부, 요구 보안 등급 같은 건 매니페스트 속성이니까 말이다.
당연한 얘기이지만 공용 컨트롤 6.0과 최소 운영체제 6.0은 서로 다른 개념이라는 걸 유의하도록 하자. 공용 컨트롤의 버전은 차라리 과거 IE의 버전(6)과 비슷한 구도이다. 일개 웹브라우저가 운영체제의 셸까지 마개조하던 시절의 잔재이다(DLL hell도 존재했었고..).

그래도 현재의 Visual C++ 2019도 최소 운영체제 버전이 6.0에서 값이 더 오르지는 않고 있다. Vista를 진짜 하한선 마지노 선으로 잡고 있는 듯하다.
그럼 다음으로 Aero를 끄고 Basic 테마에서 두 프로그램을 실행한 결과는 다음과 같다.

사용자 삽입 이미지사용자 삽입 이미지

desktop window manager가 동작하지 않으니 DwmGetWindowAttribute 함수는 실행이 실패했다.
두 경우 모두 프로그램의 외형은 짤리는 것 없이 동일하고, 테두리의 두께만이 차이가 날 뿐이다. 최신 방식으로 빌드된 프로그램은 더 두껍고, 그렇지 않은 레거시 프로그램은 얇다.

단, Aero의 동작 여부와 무관하게 최신 방식과 레거시 방식이 클라이언트 영역 크기는 동일하다는 걸 알 수 있다. 전자는 테두리가 두꺼우니 클라이언트 영역이 (284, 162)로 더 작고, 후자는 (294, 172)로 더 크다.

사용자 삽입 이미지사용자 삽입 이미지

고전 테마는 Windows 8~10에서는 더 찾아볼 수도 없는 모드가 됐는데.. 얘는 빌드 방식에 따른 차이가 전혀 존재하지 않는다. DwmGetWindowAttribute도 당연히 동작하지 않고 말이다.
단지, 얘는 크기 조절 가능한 창에 대해서는 테두리의 두께가 1픽셀.. 눈꼽만치 더 늘어난다. 그래서 창의 크기가 같을 때 클라이언트 영역의 크기가 (294, 175)에서 (292, 173)으로 2픽셀씩 더 줄어드는 걸 알 수 있다.

사실은, 대화상자를 크기 조절 가능하게 만들면 6.0이 아닌 옛날 방식으로 빌드된 프로그램이라도 앞의 Aero 내지 Basic 테마에서 창이 최신 6.0 버전이 지정된 프로그램과 동일하게 동작한다.
리소스 편집기에서 대화상자의 Border 속성을 Dialog Frame 말고 Resizing으로 지정했을 때 말이다.

그럼 마지막으로 Windows의 종결자 버전인 10에서는 창이 어떻게 뜨는지 살펴보자.
Windows 10의 프로그램 창은 외관상으로 Vista/7 같은 두툼한 테두리가 없고 슬림하다. 그럼에도 불구하고 10에서도 6.0이 지정된 프로그램은 클라이언트 영역의 크기가 레거시 방식 프로그램보다 더 작게 설정된다. 다시 말하지만 GetWindowRect의 결과는 둘 다 동일한데... 이렇게 동작해야 할 이유가 없어 보이는데도 말이다.

사용자 삽입 이미지사용자 삽입 이미지

더욱 괴이한 것은 DwmGetWindowAttribute의 실행 결과이다.
나름 DWM은 동작하고 있는지 이 함수도 값을 되돌리기는 하는데... 보다시피 리턴된 사각형이 GetWindowRect의 리턴값보다도 더 작다~! Vista/7의 Aero와는 결과가 딴판이다.
최신 6.0 방식으로 빌드된 프로그램은 (1, 1) 위치에서 (300, 200) 픽셀 크기를 찍더라도 실제로 생기는 창의 크기는 (8, 1)에서 (286, 193) 픽셀 크기 남짓으로 예상보다 꽤 작게 된다.

지금까지 나열한 실험 결과를 표로 한데 요약하면 다음과 같다. 96dpi (100%) 확대 배율에서 굴림 아니면 맑은 고딕의 완전 기본 metric 기준이다.
GetWindowRect의 값이 다 똑같이 [1,1, 301,201]인 윈도우이더라도 실제 외형과 클라이언트 영역 크기는 운영체제와 빌드 방식에 따라 저렇게 차이가 날 수 있다는 것이다.

  레거시 (2010/XP) 신형 (2012+/Vista+)
DWM Attribute client 크기 DWM Attribute client 크기
Vista/7 Aero [-4,-4, 306,206] [294, 172] [1,1, 301,201] [284, 162]
Vista/7 Basic N/A [294, 172] N/A [284, 162]
Classic N/A [294, 175] N/A [294, 175]*
Windows 10 [3,1, 299,199] [294, 171] [8,1, 294,194] [284, 161]

* resizable하지 않은 대화상자의 경우, 고전 테마에서는 레거시 및 신형 방식 모두 대화상자 외형 차이가 없다. 단지, resizable한 대화상자는 프레임 두께가 1픽셀 더 두꺼워져서 클라이언트 영역이 2픽셀씩 줄어든다.
그리고 고전 이외의 다른 테마에서 resizable한 대화상자는 레거시 방식도 신형 방식과 동일한 결과가 나온다.

그러니, 똑같은 소스 코드이더라도 최신 Visual C++ 컴파일러로 다시 빌드를 하면 예전보다 창 크기가 더 작게 나와서 UI 구성 요소의 오른쪽/아래쪽이 짤리는 부작용이 발생할 수 있다. 그런 상황이 발생하면 놀라지 말고 SetWindowPos 같은 함수에 숫자를 무식하게 하드코딩 하지 않았는지를 침착하게 점검해 보기 바란다.

옛날에는 SetWindowPos, GetClientRect 같은 함수의 msdn 설명을 보면 Windows Vista에서 보정으로 인한 동작 차이가 발생할 수 있다는 말이 '비고'란에 분명 있었다. DWM 쪽의 API를 참고하라는 말도 봤었는데 2010년대 이후부터는 삭제된 듯하다. 지금은 그런 말을 찾을 수 없다.

하지만 그런 말이 있건 없건, 창의 크기를 지정할 때는.. non-client 요소가 일체 없고 픽셀값을 반드시 곧이곧대로 지정해야만 하는 불가피한 상황이 아니라면 어지간해서는 SetWindowPos에다가 하드코딩된 값을 무식하게 전하는 일이 없어야 한다.
이 글에서 소개한 non-client 영역 크기 보정도 있고, 화면 확대 배율(DPI)도 있고.. 하드코딩 픽셀값을 쓸 수 없게 만드는 요인은 여럿 존재한다. 잘 만들어진 프로그램이라면 창 크기를 계산할 때 그런 변수들을 잘 감안해야 한다.

가령, 대화상자 리소스 템플릿에서 사용되는 단위인 DLU를 픽셀수로 보정하려면 MapDialogRect을 사용하면 된다. 이것만으로도 화면 확대 배율은 커버 가능하다.
그리고 특정 클라이언트 영역의 픽셀수와 정확하게 대응하는 window rect 크기를 구하려면 AdjustWindowRect(Ex)를 사용하면 된다.

Posted by 사무엘

2019/05/27 08:34 2019/05/27 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1624

Windows 운영체제에서 쓰이는 EXE나 DLL 같은 실행 파일들은 잘 알다시피 Portable executable이라는 포맷을 따라 만들어져 있다. 그래도 맨 앞에는 MZ로 시작하는 MS-DOS EXE 헤더가 호환성 차원에서 여전히 있으며, 거기서 가리키는 오프셋을 따라가 보면 그제서야 PE 헤더가 시작된다.

그런데 아무 EXE/DLL이나 찍어서 바이너리 에디터로 들여다 보시라. 도스 EXE stub과 실제 Windows용 실행 파일의 사이 공간에는 뭔가 4바이트 간격으로 규칙성이 느껴지는 정체불명의 데이터가 수십~수백 바이트가량 있으며, 끝에는 약속이나 한 듯 "Rich"라는 문자열이 있다. 이건 도스/Windows 어느 플랫폼에서도 쓰이지 않는 잉여 데이터이다.
이놈의 정체는 도대체 무엇일까...??

사용자 삽입 이미지

더욱 괴이한 것은.. 이건 Visual Basic, Visual C++ 같은 마소의 개발툴(링커)로 생성한 바이너리에만 존재한다는 것이다.
게다가 마소 개발툴조차도 처음부터 이랬던 게 아니다. Visual C++의 경우, 5.0의 마지막 서비스 팩(아마 3)부터 적용된 거라고 한다. 그러니 지금으로부터 20여 년 전, 대략 1997~98년 사이부터이고.. 그냥 편의상 6.0부터라고 생각해도 무방하다.

본인의 고딩 시절, Visual C++ 4.2로 빌드된 날개셋 한글 입력기 1.0 내지 PentaCombat 오목 게임의 실행 파일을 들여다보면.. 아니나다를까 일명 "Rich 헤더"가 존재하지 않는다. MZ와 PE 사이에 아무 공백이 없다.

사용자 삽입 이미지

그러나 VC++ 6으로 빌드된 날개셋 1.2부터는 응당 Rich 헤더가 추가되어 있다.

과거 Windows 9x의 경우, 내부의 프로그램들을 빌드할 때 Visual C++이 아닌 다른 자체 컴파일러를 사용했다. kernel32, gdi32, user32 같은 DLL이라든가 메모장 같은 간단한 프로그램 말이다.
여기서 유래된 바이너리들은 Rich 헤더가 존재하지 않으며, 이 관행은 Visual C++ 6이 출시된 뒤에도 Windows 98/ME까지 변함없이 이어졌다.

하지만 내장 프로그램 중에서 워드패드와 그림판, EUDC 편집기처럼 Program Files\Accessories에 들어있던 프로그램은 MFC도 사용하고 나름 Visual C++ 냄새가 났었다.
얘들은 Windows 95 시절엔 대외적으로 공개된 적이 없는 MFC 짝퉁을 사용하다가, Windows 98부터 깔끔하게 Visual C++ 5 sp3으로 빌드되기 시작했다. 그래서 얘들은 예외적으로 Rich 헤더가 포함되기 시작했다.

이런 9x와 달리 Windows NT는 운영체제 차원에서 처음부터 Visual C++ 팀과 잘 연계하는 편이었다.
9x 계열은 98에 와서야 msvcrt와 mfc42같은 Visual C++ 출신의 배포용 DLL들이 최초로 정식 포함돼 들어간 반면, NT 계열은 처음부터 메모장도 진작부터 msvcrt를 사용해 왔다. NT4는 직접 확인해 보지 않아서 모르겠지만, 2000은 모든 EXE/DLL의 내부에 Rich 헤더가 존재한다.

한편, Visual C++ 말고 델파이 같은 타 개발툴로 빌드된 실행 파일에는 Rich 헤더 같은 건 당연히 존재하지 않는다.

사용자 삽입 이미지

그럼 본론으로 들어가도록 하겠다.
마소에서는 이 헤더? 데이터? chunk?를 왜 집어넣기 시작했으며, 이 정보의 의미는 도대체 무엇일까? 엄밀히 말하면 헤더라고도 볼 수 없는 단순 데이터일 뿐인데 말이다.
놀랍게도 마소에서는 이에 대해서 지금까지 공식적인 답변을 한 번도 제공하지 않았으며 undocumented, 묵묵부답으로 일관한 듯하다. 빌드 시에 이 헤더를 제외시키는 옵션도 없다.

그래서 일각에서는 흉흉한 음모론까지 나돌기 시작했다. 제일 유명한 게 뭐냐 하면, 이건 이 바이너리를 빌드한 컴퓨터 환경을 식별하는 정보라는 것이다.
그래서 이 exe/dll이 악성 코드로 밝혀져서 경찰에 수사를 의뢰하게 되면.. 이 정보로부터 개발자의 컴퓨터를 추적할 수 있고, 따라서 악성 코드를 만든 사람도 아무 단서가 없는 것보다는 더 용이하게 색출할 수 있다고 한다..;;

이거 마치 컬러 복사기 얘기처럼 들린다. 컬러 복사기의 결과물에는 아주 정교한 워터마크가 사람 눈에 안 띄게 몰래 새겨진댄다. 그래서 어설픈 컬러 복사 위조지폐가 발견되면 그 워터마크를 토대로 복사기의 일련번호를 추적할 수 있으며, 이를 통해 범인도 색출할 수 있다고 한다. 허나 본인은 그런 게 실제로 존재한다고 믿지는 않는다.

뭐, 복사기는 그렇다 치고.. 실행 파일의 경우, 상식적으로 생각해 봐도 저건 개발 컴퓨터의 색출 목적으로 사용하기에는 보안이 너무 허술하다.
진짜 나쁜 마음 품은 악성 코드 개발자라면.. 그 Rich 헤더 부분을 후처리로 몽땅 0으로 칠해서 일부러 지워 버리기만 해도 자기 정체를 숨길 수 있으며, 그래도 악성 코드의 동작에는 하등 문제될 것 없다.

이 경우 PE 헤더의 다른 필드에 존재하는 checksum 정보가 어긋나서 파일이 변조되었다는 것이 감지되겠지만, 이것도 일부러 기재하지 않았다고 0을 집어넣어 버리면 그만이다. 더구나 보안을 위해 소프트웨어에서 이미 있던 이스터 에그도 다 없앴고 요즘은 바이너리 차원에서 철저히 예측 가능한 reproducible build까지 추구하는 마당에, 이런 식의 비밀 식별 정보는 마소의 개발 방침 이념과도 어울리지 않는다.

그러니 프로그램을 빌드할 때마다 내 컴퓨터의 맥 어드레스가 유출된다는 식으로 불안해할 필요는 없다. 하지만 Rich 헤더의 정체는 여전히 베일에 싸여 있었다.
그래서 전세계의 많은 컴덕과 해커들이 의문을 품기 시작했으며, 어떤 용자는 MS에서 개발한 링커 프로그램을 아예 근성으로 리버스 엔지니어링까지 했다. 그래서 이 데이터에 대한 여러 사실들을 밝혀냈다.

가장 먼저.. Rich 헤더는 4바이트 덩어리 단위로

A B B B X1 Y1 X2 Y2 ... "Rich" B

대체로 요런 형태로 돼 있다.
끝의 Rich 다음에 나오는 마지막 double word인 B가 일종의 난수이며, 암호화 key이다. 그리고 Rich 앞에 있는 숫자들은 바로 그 B와 xor을 해 주면 실제값을 얻을 수 있다.

그러면 맨 첫째 값 A는 언제나 0x44 0x61 0x6E 0x53.. 문자 형태로 늘어놓으면 "DanS"라는 시그니처가 된다.
다음으로 시그니처 뒤에 이어지는 몇 개의 B는 16바이트 단위 padding을 맞추기 위한 0값인 것 같다. 자기 자신을 xor 하면 결과는 언제나 0이 되니 말이다.

그 뒤 이어지는 같은 값들은 숫자 2개가 X, Y 형태로 한 pair를 이룬다. X는 이 바이너리를 생성하는 데 쓰인 툴(C 컴파일러, C++ 컴파일러, 리소스 컴파일러, 어셈블러 등..)과 버전(빌드 번호)을 나타내며, Y는 그 도구를 이용하여 생성된 아이템.. 이를테면 obj 파일의 개수를 나타낸다고 한다.

그럼 이런 정보들은 링커가 어디에서 얻어서 집어넣는가 하면.. 당연히 자기가 input으로 받아들이는 obj 파일로부터이다. 그렇잖아도 1997~98년을 전후해서 obj 파일 포맷이 바뀌었다고 한다.
obj야 소스 코드를 번역한 기계어 코드의 뭉치일 뿐이니 20년 전이나 지금이나 컴퓨터 아키텍처가 변함없는 한 바뀔 일이 없으며, 내부 구조가 바뀌어 봤자 새 버전의 컴파일러/링커가 인식하는 새로운 chunk 정도나 추가되는 게 전부일 것 같은데.. 꼭 그렇지는 않은 모양이다.

하긴, 같은 양의 코드를 빌드해도 요즘 컴파일러는 예전에 비해 obj의 파일 크기가 점점 더 커지고 있어 보이긴 하다. 디버깅 또는 최적화와 관련된 온갖 힌트와 메타정보들이 첨가되어서 그런 것 같다.
그래도 obj는 소스 코드를 빌드할 때마다 새로 생성되는 일회용 임시 파일일 뿐이니, 포맷에 하위 호환성 걱정 따위는 할 필요가 없을 것이다. 날려 봤자 빌드만 다시 하면 될 일이고..

아무튼 흥미로운 사실을 알게 됐다.
개인 정보 유출은 아니고, 그렇다고 디버깅과 관련된 힌트도 아니고 저런 정보가 마소의 개발툴에서 도대체 왜 들어갔는지는 여전히 오리무중이다만.. 그래도 내역을 까맣게 모르던 시절보다는 상황이 나아졌다.
본인이 이 글을 쓰기 위해 검색하고 참고한 외국 사이트는 다음과 같으니,  더 자세한 관심이 있는 분은 참고하시기 바란다.

The devil’s in the Rich header
Microsoft's Rich Signature (undocumented)
The Undocumented Microsoft "Rich" Header
Article: Things They Didn't Tell You About MS LINK and the PE Header

저 외국 사이트들의 설명에 따르면, Rich와 DanS는 모두 1990년대에 마소의 Visual C++ 팀에서 근무하고 이 데이터의 구현에 직접 관여했던 프로그래머의 이름에서 유래되었을 가능성이 높다고 한다. 그 이름은 각각 Richard Shupak와 Dan Spalding이다. ㄷㄷㄷ;;
MZ, PK만큼이나 프로그래머의 이름이 파일 포맷에 각인된 사례라 하겠다.

참고로 전자 리처드의 경우, Windows SDK에서 psapi.h의 작성자로 이름이 나와 있기도 하다.
PSAPI는 Windows NT 계열용으로 실행 중인 프로세스 정보를 조회하는 EnumProcesses, GetModuleFileNameEx 등의 함수를 정의하는 라이브러리인데, 작성 날짜가 무려 1994년이라고 적혀 있다.

Posted by 사무엘

2019/05/21 08:31 2019/05/21 08:31
,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1621

1.
먼 옛날에 기술이란 게 지금보다 비싸고 희귀하던 시절에는 컴파일러 자체가 유료화 대상이었다. Windows 플랫폼 SDK에 같이 들어있는 무료 기본 컴파일러는 상업용으로 따로 판매되는 Visual C++의 컴파일러와 같은 빡센 최적화 기능이 없었다. 사용자가 작성한 코드대로 돌아가는 실행 파일은 만들어 주지만 최적의 성능을 발휘하는 형태로 만들지는 않았다는 것이다.

그러다가 Visual C++ 2005 Express를 기점으로 고성능 컴파일러도 무료화되고 기본 제공되기 시작했다.
요즘 컴파일러의 최적화 테크닉은 학교에서 배우는 것처럼 (1) 쓰이지 않는 변수나 코드 제거, (2) 값이 뻔한 수식은 한 번만 미리 계산해 놓고 loop 밖으로 옮기기, (3) 변수는 적절하게 레지스터 등재, (4) 함수는 적절하게 인라이닝, (5) 최대한 병렬화, (6) CPU 명령 하나로 처리 가능한 단위로 묶기, (7) switch 분기를 매번 번거로운 if문이 아니라 테이블 참고 방식으로 변경... 같은 미시적인 것만이 그림의 전부가 아니다.

그야말로 번역 단위(소스 코드) 간의 경계를 넘나들고 컴파일러와 링커의 역할까지 재정립하면서까지 저런 최적화 테크닉을 적용할 부위를 판단할 필요가 있다. 그래서 컴파일러에는 전역 최적화라는 옵션이 도입되고, 링커에는 link time code generation이라는 옵션이 추가되어 서로 연계한다. 뭐, 대단한 기능이긴 하지만 이 정도로 최적화를 쥐어짜서 성능이 더 나아진 것 대비 프로그램의 빌드 타임이 너무 길어지는 건 별로 마음에 안 든다.

그리고 세상에 C++ 컴파일러가 MS 것만 있는 것도 아니고, 경쟁사들의 제품도 갈수록 성능이 좋아지고 가격이 저렴해지고 있으니 optimizing compiler 정도는 충분히 대중적인 영역으로 내려가게 됐다.

2.
한편, Visual Studio Express 에디션은 기업에서 사용이나 상업용 소프트웨어 개발까지 아무 제약 없는 무료인 대신, 리소스 편집기와 MFC 라이브러리가 없다. 지금도 그런지는 모르겠지만 처음엔 심지어 64비트 빌드 기능도 없었다.
그 뒤 VC++ 2013부터는 MFC와 리소스 편집기가 다 있는 Community 에디션이란 게 나왔다. 얘는 기능 제약이 없는 대신 개인 개발자나 스타트업 중소기업 수준까지만(인원 얼마 이하, 연 매출 얼마 이하..) 무료이다.

서로 조건이 다른 무료이기 때문에 VC++ 2013, 2015, 2017까지는 한 버전에 대해서 express와 community 에디션이 모두 나왔다. 하지만 express는 장기적으로는 community로 흡수되고 없어질 것으로 보인다.

3.
이렇게 프로그램을 기본적으로 만들고 빌드하는 데 필요한 도구들은 슬슬 무료화 단계에 들어섰고, 그 다음으로는 그냥 빌드만 하는 게 아니라 제품의 품질을 높이는 데 도움을 주는 도구, 대규모 공동 작업과 테스트를 위해 필요한 전문 도구들이 아직 유료이다. 어떤 것은 Visual Studio의 엔터프라이즈 같은 제일 비싼 에디션에서만 제공된다.

Visual C++ 2008에서는 GUI 툴킷이 feature pack을 통해 무료로 풀렸더니 2012부터는 정적 분석 도구가 무료로 풀렸다. 이걸 처음 써 본 소감은 꽤 강렬했다.

사실, 본인도 그저 닥치는 대로 코딩과 디버깅만 하는 것 말고 소프트웨어공학적인 개발 프로세스라든가 기술 문서 잘 쓰는 요령, 변수와 함수의 이름 잘 짓는 요령, 전문적인 테스트 절차와 프로파일링 같은 것을 잘 알지 못한다. 더 발전하려면 그저 무료로 풀려 있는 도구들만 쓰는 게 아니라 그 너머에 있는 도구들도 뭔지 알고 필요성을 공감할 정도는 돼야 할 텐데..

그렇잖아도 날개셋 한글 입력기도 어떤 형태로든 버그가 없었던 적은 거의 없었다. 그리고 개인 프로젝트뿐만 아니라 회사 업무도 늘 깔끔하게 마무리 짓지는 못해서 버그가 있는 채로 제품을 내고 후회한 적이 적지 않았다.
만들어진 제품의 품질을 검증하는 절차 자체는 소프트웨어뿐만 아니라 건축· 건설을 포함해 공학의 어느 분야에서나 다 필요하고 존재할 것이다. 그 가운데에 무형의 지적 산물인 소프트웨어만이 갖는 특수성 때문에 이 바닥에서만 통용되는 방법론도 있겠지만 말이다.

아무튼, 프로그래밍 툴의 제작사들이 흙 파서 장사하는 건 아닐 테니.. 이런 어마어마한 컴파일러쯤은 딴 데서 개발 비용을 회수할 통로가 다 있으니까 무료로 풀어 놓는 게 가능할 것이다.

4.
(1) 이렇듯, Visual Studio를 포함해 개발툴들은 갈수록 기능이 좋아지고 기술들이 상향평준화되고 있다. 일례로, 2017인가 2019부터는 코드 편집기에 일반 컴파일러의 경고/에러뿐만 아니라 정적 분석 결과까지 초록색 밑줄로 띄워 주는 걸 보고 무척 감탄했다. (NULL 포인터 역참조가 일어날 수 있습니다 따위..)

(2) 2017/2019부터는 도움말과 API 레퍼런스는 몽땅 인터넷으로 처리하고 로컬 오프라인용 Help Viewer를 더 관리하지 않으려는지? 설치 화면에서는 기본 선택돼 있지도 않으며, 컨텐츠 역시 2015 내용 이후로 달라진 게 없어 보인다.

(3) 그리고 Spy++의 64비트 버전은 왜 도구 메뉴에서 숨겨 버린 걸까?
한 프로그램만으로 32비트와 64비트를 통합해서 잘 동작하는 것도 아니고, "이 창의 메시지를 들여다보려면 64비트 Spy++를 실행해 주십시오" 안내를 해 주는 것도 아닌데 왜 이런 조치를 취했는지 이해할 수 없다. 사실은 32와 64비트를 가리지 않고 아주 seamless하게 동작하는 Visual Studio의 디버거가 정말 예술적인 경지의 대단한 도구이긴 하다.

(4) 시대 유행과 별 관계 없는 부분은 계속해서 안 바뀌기도 하는 것 같다.
도구상자를 customize할 때 콤보 박스의 길이 조절을 200x 시절처럼 마우스 드래그로 할 수 없고.. 2010 이래로 그냥 픽셀수 입력으로 때운 걸로 굳히려는가 보다.

무려 7년 전 글에서 지적했던 2. 메뉴 편집기의 우클릭 버그, 3. 툴바 편집기의 화면 잔상 역시, Visual Studio 2019 현재까지도 전혀 고쳐진 게 없다.
그리고 예전엔 안 그랬던 것 같은데 2019는 Visual Studio 텍스트 에디터의 폰트를 딴 걸로 바꾸고 껐다가 다시 켜서 곧장 반영되는 걸 확인까지 했는데.. 켠 채로 절전 상태로 몇 번 갔다가 돌아오면 폰트가 컴터에 따라 아주 가끔은 돋움이나 Courier New 같은 기본 글꼴로 돌아오는 것 같다.

Posted by 사무엘

2019/05/10 08:36 2019/05/10 08:36
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1617

Windows의 Region

Windows API에는 region(영역)이라는 일종의 자료구조 라이브러리가 있다. 얘는 2차원 래스터(픽셀/비트맵) 그래픽 공간에서 각 픽셀별로 "영역에 포함되냐 안 되냐"라는 일종의 '집합'을 표현한다.
그리고 운영체제는 이 자료구조를 이용하여 각종 그래픽이 그려지는 영역을 정한다. 즉, region은 클리핑(clipping) 영역을 표현하는 데 쓰인다는 것이다.

도스 시절의 여느 그래픽 라이브러리에도 간단한 사각형 영역에만 그림이 그려지게 하는 초보적인 수준의 클리핑 기능은 있었다. 하지만 Windows의 region은 여러 사각형이 겹친 것, 임의의 다각형, 원 등 아무 모양이나 표현하고, 그 영역 안에만 그림이 그려지게 만들 수 있다.

그도 그럴 것이 Windows 같은 GUI 운영체제라면 창들의 Z-order 같은 걸 구현하는 과정에서 밥 먹고 맨날 하는 짓이 정교한 클리핑일 수밖에 없게 된다. 뒤쪽에 있는 창 내용은 앞쪽에 있는 창의 영역을 침범하지 않고 그려져야 하기 때문이다. 그러니 그런 기능을 사용자들에게도 쓰라고 제공해 주는 게 결코 이상한 일이 아니다.

region은 가장 먼저, (1) 직사각형, 타원, 모서리가 둥근 직사각형, 다각형(CreatePolygonRgn, CreatePolyPolygonRgn)처럼.. 속이 닫힌 도형을 그리는 다양한 API를 통해 생성할 수 있다. 당연히 그 도형의 모양이 영역의 모양이 된다.

다음으로, GDI가 제공하는 기능인 (2) path로부터 region을 생성할 수 있다(PathToRegion). path란 마치 윤곽선 글꼴 글립처럼 직선(MoveTo, LineTo)과 곡선(PolyBezirTo)을 임의로 조합하여 어떤 궤적이나 경계선을 기술하는 자료구조이다. region과 달리 벡터 기반이며, 별도의 자료구조로 존재하는 게 아니라 DC의 내부 상태에 종속인 형태로 보관된다는 차이가 있다.

path를 사용하면 경계선이 베지어 곡선인 region을 만들 수 있으며, TextOut 같은 글자 출력 함수를 path에다 넣으면 임의의 글자의 윤곽선도 따서 고스란히 region으로 만들 수 있다. 커다란 두 글자를 포개 놓은 뒤, 겹치는 영역만 다른 색깔로 칠하는 게 region으로는 가능하다. 그 비결은 바로...

사용자 삽입 이미지

(3) CombineRgn이라는 함수를 통해 region 간에 일종의 집합 연산을 할 수 있기 때문이다. 두 region의 교집합, 합집합, 차집합을 구함으로써 더 복잡한 형태의 region을 만들 수 있다.

위의 그림을 보아라. 운영체제나 하드웨어 차원에서 제공되는 layer이나 alpha channel 합성 같은 걸 쓴 게 아니다. 옅은 회색(B), 짙은 회색(S), 검정(겹침)이 차지하는 영역을 2차원적으로 완전히 따로 떼어내서 서로 완전히 다른 색깔과 무늬로 칠할 수 있다. 뚝 떨어진 영역도 당연히 같이 감안해서 말이다. 이런 게 평범한 글자 찍기 API로는 가능하지 않을 것이다.
다만, region은 anti-aliasing을 지원하지 않는 boolean 흑백 자료구조이다 보니, 글자 경계가 거친 것은 아쉬운 점이며 요즘 그래픽 기술의 트렌드와 맞지 않다.

그리고 끝으로.. region은 내부 자료구조를 어느 정도 노출해 주고 있기까지 하다. 그래서 그걸 직통으로 저장하고 불러오는 식으로 생성할 수도 있다. 데이터를 얻는 함수는 GetRegionData이고, (4) 그걸로부터 region을 다시 생성하는 함수는 ExtCreateRegion이다.

어떤 방식으로 region을 생성했건, 얘는 내부적으로 크게 세 부류로 나뉜다. region을 생성하거나 받아들이는 함수들이 그 region의 유형을 리턴값을 통해 알려주기도 한다.

  • 아무 영역도 없는 공집합인 NULLREGION
  • 직교좌표 직사각형 하나로만 구성된 SIMPLEREGION
  • 그 외의 다른 모든 모양을 표현하는 COMPLEXREGION.. 얘는 내부적으로 2개 이상의 사각형, 아니 scan line들로 구성된다.

자연에서 관찰되는 힘이라는 것들이 중력이나 원자력과 관계 있는 게 아니면 나머지는 출처가 몽땅 전자기력이듯이(폭발력, 마찰력, 탄성, 자석, 정전기, 표면장력, 생물 근육 등등등..),
그리고 사람이 생성(...)되는 방식이 흙을 빚어서 직통, 여자의 씨 같은 극소수 예외를 제외하면 나머지 수십~수백 억의 인간들은 몽땅 남자의 씨 기반이듯이.. 그런 것처럼 region도 일상생활에서 보는 단순하지 않은 물건들은 몽땅 complex라고 생각하면 되겠다.

region이 내부적으로 구현된 방식의 특성상(벡터/오브젝트 기반이 아닌 비트맵/픽셀 스캔라인 기반) 무한을 구현할 수 있지는 않으니, 집합 연산에서도 not 연산인 여집합이 지원되지는 않는 걸 볼 수 있다. 차라리 이미 있는 집합끼리 차집합이 대신 지원되고 말이다.

region을 식별하는 핸들 내지 포인터 자료형은 HRGN이다. 그런데 Create...처럼 HRGN을 리턴값으로 주는 함수 말고, Get...Rgn, CombineRgn 이런 이름이면서 HRGN을 인자로 받는 함수들은... 이미 있는 HRGN에다가 값만 바꿔서 넣어 준다. 그런 함수를 쓰려면 null region 하나라도 미리 미리 생성해서 전해 줘야 한다.

그런데 Windows API에는 많고 많은 region 생성 함수들 중에 null region만을 달랑 생성하는 함수는 의외로 없다. 좌표가 모두 0인 직사각형.. CreateRectRgn(0,0,0,0) 이게 그냥 텅 빈 region을 생성하는 역할을 한다. 좀 교묘한 점이다.

그럼 이 region는 어떤 용도로 쓰이며, 할 수 있는 일이 무엇일까? 본인이 생각하기에 다음과 같다.

1. 단독

그냥 저거 자체만으로 뭔가 2차원 공간 상의 기하/집합 알고리즘 구현체로.. 다른 GDI API와의 연계 없이, 심지어 명령 프롬프트용 프로그램에서도 쓰일 수 있다. 펜, 브러시, 글꼴, 비트맵 같은 타 오브젝트들이 DC와의 연계 없이는 거의 쓸모없는 것과 굉장히 대조적이다.
하지만 region이 그렇게 단독으로 쓰이는 경우는 그리 많지 않아 보인다.

2. 창의 invalid 영역 표현

어떤 창의 앞을 가리던 다른 창이 없어지고 내 창의 내용이 다시 그려지게 됐을 때.. 그 이름도 유명한 WM_PAINT 메시지가 날아온다.
BeginPaint와 함께 제공되는 DC는 창 전체가 아니라 정확하게 다시 그려져야 하는 영역에만 그림이 그려지도록 클리핑 처리가 돼 있는데, 이 영역이 말 그대로 region으로 표현되며 GetUpdateRgn 함수를 통해 얻어 올 수 있다.

WM_PAINT 때 이 영역에 대해서 PtInRegion이나 RectInRegion을 적절히 호출하면서 그림을 그리면, 무식하게 화면 전체를 그리는 것보다 프로그램 성능과 반응성을 향상시킬 수 있다.
물론 DC 차원에서 클리핑 처리가 되는 것만으로도 화면 전체를 그리는 것보다는 속도가 향상되지만, 애초에 그리기 요청을 안 하고 CPU 계산을 안 하는 게 더 낫기 때문이다.

3. 내부 클리핑

운영체제뿐만 아니라 사용자 역시 임의의 region을 생성해서 클리핑 용도로 쓸 수 있다. 비트맵이 사각형 모양이 아니라 원 모양으로만 뿌려진다거나, 특정 글자 모양으로만 뿌려지게 할 수 있다는 것이다.
그렇게 하려면 HRGN을 DC에다가 지정하면 된다. 이것은 SelectObject로 해도 되고 SelectClipRgn으로 해도 된다. 완전히 동일하다.
단지, 클리핑을 해제하는 것은 SelectClipRgn로만 가능하다. HRGN 값으로 NULL을 전해야 하기 때문이다. null region은 그림이 전혀 그려지지 않게 하는 효과를 낼 테니까..

HRGN은 기술적으로는 HPEN, HBRUSH, HBITMAP, HFONT와 마찬가지로 여러 GDI 오브젝트 중 하나로 취급된다. 상술한 바와 같이 DC에 SelectObject 될 수 있으며, 소멸 함수가 DeleteObject인 것까지도 동일하다.
하지만 얘는 다른 오브젝트들과 달리 select나 get 될 때 내부 메모리가 복사될 뿐, 핸들값 자체를 주고 받지는 않는다. 즉, HRGN은

HRGN oldRgn = (HRGN)SelectObject(dc, newRgn);
(.....)
SelectObject(dc, oldRgn);

이런 식으로 운용되지 않는다는 것이다. 옛날 핸들값을 보관하고 되돌리는 식의 절차가 필요하지 않다.
DC와 region은 서로 따로 논다. 이렇게 설정한 뒤에 원래 있던 HRGN 핸들은 곧장 삭제해 버려도 된다.

본인은 MFC의 CGdiObject처럼 GDI 객체 핸들만 한데 뭉뚱그린 템플릿 클래스를 만들어서 쓰고 있다. (소멸자에는 DeleteObject가 있고..)
그런데 다른 오브젝트들은 template<T> Handle(T v=NULL) 이런 식으로 NULL이 default 인자인 생성자를 만들어서 초기화할 수 있는 반면,
HRGN에 대해서는 인자가 없는 경우에 대해 specialize된 생성자를 따로 만들어서 이때는 null region을 생성해 놓게 했다. 그래야 이놈을 region을 얻어 오는 다른 함수에다가 인자로 줄 수 있기 때문이다.

4. 칠하고 그리는 공간

그리고 region 자체가 도형을 나타내니 그 모양대로 클리핑이 아니라 내부를 칠하는 용도로 응당 활용할 수 있다. 내부를 칠하는 FillRgn과, 내부의 경계선을 그려 주는 FrameRgn이라는 함수가 제공된다.
흥미로운 것은 경계선을 그릴 때도 pen 대신 brush가 쓰인다는 것이다. region은 path와 달리 벡터 드로잉이 아니기 때문이다. 경계선은 그냥 픽셀 차원에서 색깔이 변하는 곳을 얼추 감지해서 표시해 주는 것일 뿐이다.

5. 창 자체의 외형

끝으로, region은 윈도우의 모양을 지정하는 용도로도 쓰인다. 통상적인 사각형 모양이 아니라 리모콘 같은 다른 물건처럼 생긴 프로그램 창, 현란한 스플래시 윈도우, 내부에 구멍까지 있는 윈도우.. 전부 SetWindowRgn 함수의 산물이다.
한번 SetWindowRgn에다 전해 준 HRGN은 이제 운영체제가 관리하기 때문에 사용자가 DeleteObject 하지 말아야 한다고 문서에 거듭 명시되어 있다.

SetWindowRgn를 지정하는 순간부터 그 창은 운영체제가 non-client 영역과 테두리 경계에 기본 제공하는 각종 테마와 반투명 프레임, 둥그런 테두리, 그림자 효과들로부터 완전히 열외된다. 그 대신 고전 테마의 완전 무미건조한 테두리만이 그려진다. 일반적으로는 당연히 아래처럼 그려질 프로그램 창이 위처럼 그려지게 된다는 뜻이다.

사용자 삽입 이미지

그런 창은 어차피 non-client 영역이 전혀 없이 외형을 사용자가 완전히 customize하는 형태로 쓰일 테니 말이다. 제목 표시줄과 테두리의 외형은 보급품 그대로이면서 중앙에만 region을 지정해서 총알 구멍 같은 게 숭숭 뚫린 프로그램 창 같은 건 만들 수 없다는 뜻이다.

보통은.. 공용 컨트롤 6.0 매니페스트가 없는 프로그램의 경우, 버튼 같은 컨트롤들이 구닥다리 고전 스타일로 그려지고 non-client 영역은 운영체제가 자동으로 쌈빡하게 그려 준다. (그림에서 아래 오른쪽) 그런데 SetWindowRgn을 지정하면 반대로 컨트롤들은 정상적으로 그려지는데 non-client 영역이 고전 스타일로 돌아간다는 게 흥미롭다.

단, 지난 Windows 2000부터는 SetWindowRgn가 아닌 다른 방법으로도 사각형 모양이 아닌 윈도우를 표현할 수 있게 되었다. 바로 layered window이다. WS_EX_LAYERED 스타일을 준 윈도우에다가 SetLayeredWindowAttributes 함수를 호출하면 (1) 이 창에 대해서 투명도를 지정할 수 있고, (2) 특정 RGB 값을 color key로 지정해서 그 색깔은 투명으로 처리할 수 있다. 배경색을 칠하는 것만으로 그 부위가 투명해지니, 번거로운 region보다 훨씬 더 편리해지기까지 했다.

과거 MS Office 97~2000 시절의 흑역사 중에는 'Office 길잡이'라는 "취지만 좋았다" 급의 물건이 있었다. 애니메이션 캐릭터가 화면에 나타나서 돌아다니는데.. 첫 도입되었던 97은 캐릭터가 사각형 창 안에 갇힌 형태였던 반면, 2000부터는 배경 없이 창이 시시각각 애니메이션 캐릭터 모양으로 변했다.

사용자 삽입 이미지사용자 삽입 이미지

이게 Windows 2000에서는 하드웨어빨을 탄 layered window로 비교적 간편하게 구현됐던 반면.. 9x에서는 일일이 window region을 바꿔 가면서 동작했다. 그 밑의 창은 매번 WM_PAINT가 발생하면서 성능 페널티가 장난이 아니었을 것이다.

본인은 먼 옛날에 이런 프로그램도 구경한 적이 있었다. 실행하면 만화영화 그림체로 그려진 병아리 한 마리가 마치 저 Office 길잡이처럼 튀어나왔는데, 그냥 가만히 있는 게 아니라 각종 프로그램 창들 위를 돌아다녔다. 나름 중력도 구현했던지라, 마우스로 집어다가 공중에다 옮겨 놓으면 아래의 근처에 있는 창으로 떨어지기까지 했다. 내 기억이 맞다면 꽤 재미있는 프로그램이었는데.. 지금 인터넷으로 다시 검색하고 구할 길이 없다.

이 프로그램에 대해서 본인이 현재 기억하고 있는 건 아마 일본에서 만들어진 걸로 추정된다는 것, 그리고 무려 Windows 3.x용 16비트 프로그램이었다는 것이다. 그 열악한 Windows 3.x에서도 타이머를 걸어서 최소화 아이콘에다가 애니메이션을 구현하고, 창의 경계가 시시각각 곡선 윤곽으로 바뀌는 저런 액세서리 프로그램이 만들어지기도 했다.

이렇듯, SetWindowRgn을 이용해서 이런 재미있는 활용을 할 수 있는데.. 날개셋 한글 입력기에서 사각형이 아닌 모양의 창이 나타나는 곳은 마우스 휠을 눌렀을 때 나타나는 동그란 자동 스크롤 앵커가 유일하다.

사용자 삽입 이미지

에디트 컨트롤은 자동 스크롤 모드가 없고, MS 오피스 제품들은 동그란 테두리 없이 그냥 배경에다가 회색 화살표가 나타나는 듯하지만.. 마소에서 만든 웹 브라우저(IE, Edge)에서는 앵커 윈도우가 나타난다. 날개셋의 앵커 윈도우도 먼 옛날에 얘를 참고해서 만들어진 것이다. 맨 처음에는 region만 쓰다가 이내 layered window도 사용하도록 형태가 바뀌었다.

여담: 좌표계 관련 문제

아, region의 경계면과 관련해서 주의해야 할 점이 있다.
같은 좌표를 줬을 때, 직사각형은 pen으로 그려지는 테두리와 region의 영역이 픽셀 단위로 정확하게 일치한다. 다시 말해 같은 RECT rc에 대해서 CreateRectRgn + SetClipRgn을 한 뒤에Rectangle을 호출한 결과는 클리핑을 안 했을 때와도 동일하다.

하지만 타원(Ellipse vs CreateEllipticRgn)이나 폴리곤(Polygon vs CreatePolygonRgn) 같은 다른 도형에 대해서는 이것이 성립하지 않는다. region은 오른쪽과 아래 끝의 1픽셀이 미묘하게 잘린다.

//직사각형
CRgn rg; CRect rc(10, 10, 80, 80);
rg.CreateRectRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Rectangle(rc);
rc.OffsetRect(40, 40); dc.Rectangle(rc);

//원
CRgn rg; CRect rc(110, 10, 180, 80);
rg.CreateEllipticRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Ellipse(rc);
rc.OffsetRect(40, 40); dc.Ellipse(rc);

//폴리곤으로 표현한 직사각형
CRgn rg; POINT pt[4] = {
{10, 100}, {80, 100}, {80, 170}, {10, 170} };
rg.CreatePolygonRgn(pt, 4, ALTERNATE);
dc.SelectClipRgn(&rg); dc.Polygon(pt, 4);

이 코드를 실행한 결과는 다음과 같이 차이가 난다.

사용자 삽입 이미지

폴리곤으로 직사각형 좌표를 지정해 줘도, 아예 직사각형 전용 생성 함수를 줬을 때와 달리, region은 영역이 살짝 덜 생긴다. 그래서 그 region 안에서 동일 좌표로 도형을 직접 그려 보면 테두리의 오른쪽과 아래쪽이 잘린다.

이를 감안해서 원형 region을 생성할 때는 그리기 함수일 때보다 1픽셀 정도 더 크게 원을 그리면 잘리는 현상은 막을 수 있다. 하지만 그래도 그리기 함수와 region 함수는 경계 계산 결과가 서로 미묘하게 달라서 직사각형일 때처럼 깔끔하게 일치하는 모양이 나오지 않는다.
그러니 region 자체의 경계를 그려 주는 FrameRgn 함수를 대신 쓸 수밖에 없다. 허나, 얘가 그려 주는 테두리는 전문적인 원 그리기 함수에 비해 표면이 거칠며 별로 예쁘지 않다.

본인은 처음에 이런 특성을 몰라서 한동안 삽질을 했었다. 이럴 때도 region 대신 layered window는 순수하게 그리기 결과에 따라서 투명색을 자동으로 처리해 주니 더욱 유용하다.

Posted by 사무엘

2019/04/12 08:34 2019/04/12 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1607

1. 재부팅 이벤트

본인은 지금까지 Windows가 시스템 종료/재시작/로그아웃 될 때.. 메시지에 즉각 응답하면서 정상적으로 돌아가는 프로그램이라면 종료 처리도 당연히 정상적으로 되는 줄로 알고 있었다.
사용자가 [X] 버튼이나 Alt+F4를 눌러서 종료할 때와 완전히 동일하게 말이다. WM_CLOSE에 이어 WM_DESTROY, WM_NCDESTROY가 날아오고, WM_QUIT이 도달하고, message loop이 종료되고, MFC 프로그램으로 치면 ExitInstance와 CWinApp 소멸자가 호출되고 말이다.

왜냐하면 시스템이 종료될 때 발생하는 현상이 언뜻 보기에 정상적인 종료 과정과 동일했기 때문이다. 저장되지 않은 문서가 있는 프로그램에서는 문서를 저장할지 확인 질문이 뜨고, 운영체제는 그 프로그램 때문에 시스템 종료를 못 하고 있다고 통보를 한다. 좋게 WM_CLOSE 메시지를 보내는 걸로 반응을 안 하는 프로그램에 대해서만 운영체제가 TerminateProcess 같은 강제 종료 메커니즘을 동원할 것이다.

그런데 알고 보니 그게 아니었다.
정상적인 종료라면 ExitInstance 부분이 실행되어야 할 것이고 프로그램 설정들을 레지스트리에다 저장하는 부분도(본인이 구현한..) 실행돼야 할 텐데, 내 프로그램이 실행돼 있는 채로 시스템 종료를 하고 나니까 이전 설정이 저장되어 있지 않았다.

꼭 필요하다면 WM_ENDSESSION 메시지에서 사실상 WM_DESTROY 내지 ExitInstance와 다를 바 없는 cleanup 작업을 해야 할 듯했다. 뭐, 날개셋 한글 입력기에서 이 부분이 반영되어 수정돼야 할 건 딱히 없었지만 아무튼 이건 내 직관과는 다른 부분이었다.

시스템이 종료될 때 발생하는 일은 딱히 디버거를 붙여서 테스트 가능하지 않다. =_=;; 로그 파일만 기록하게 해야 하고, 매번 운영체제를 재시작해야 하니 가상 머신을 동원한다 해도 몹시 불편하다. 꽤 오래 전 일이 됐다만, 날개셋 외부 모듈이 특정 운영체제와 특정 상황에서 시스템 종료 중에 운영체제 시스템 프로세스 안에서 죽는 문제가 발생해서 디버깅을 한 적이 있었다. 몹시 골치 아팠었던 걸로 기억한다.

2. 파일의 동등성 비교

파일 이름을 나타내는 어떤 문자열이 주어졌을 때,

  • 상대 경로(현재의 current directory를 기준) vs 절대 경로
  • 과거 Windows 9x 시절이라면 ~1 같은 게 붙은 8.3 짧은 이름 vs 원래의 긴 이름
  • 대소문자 (Windows는 대소문자 구분이 없으므로. 그런데 이것도 A~Z 26자만 기계적으로 되나? 언어별로 다른 문자의 대소문자는?)
  • 그리고 옵션으로는 정규 DLL search path와 환경 변수

이런 것들을 몽땅 다~ 감안해서 다음과 비스무리한 일을 하는 가벼운 API가 좀 있으면 좋겠다. 보다시피 동일한 파일을 표현하는 방법이 굉장히 다양하기 때문이다.

  • 두 개의 파일 명칭이 서로 동일한 파일인지 여부를 판단. 당연히 파일을 직접 open/load하지 않고 말이다.
  • 그 파일의 대소문자 원형을 유지한 절대 경로. 일명 '정규화된' 이름을 되돌린다. 이게 동일한 파일은 물리적으로, 절대적으로 동일한 파일임이 물론 보장된다.
    참고로 Windows API 중에서 얼추 비슷한 일을 한다고 여겨지는 GetFullPathName이나 GetModuleFileName 함수는 절대 경로 말고 파일의 대소문자 원형 복원이 제대로 되지 않는다.
  • 혹은 그 파일의 시작 지점이 디스크에서 물리적으로 어디에 있는지를 나타내는 64비트 정수 식별자를 구한다. 포인터가 아니지만 파일의 동등성 여부 판단은 가능한 값이다. 정수를 넘어 UUID급이어도 무방함.

본인은 비슷한 목적을 수행하기 위해, 그냥 두 파일을 CreateFileMapping으로 열어 보고 리턴된 주소가 동일하면 동일한 파일로 간주하게 한 적이 있었다. 핸들 말고 MapViewOfFile 말이다. 본질적으로 동일한 파일이라면 운영체제가 알아서 같은 주소에다 매핑을 하고 레퍼런스 카운트만 증가시킬 테니까..
Windows 9x에서는 주소가 시스템 전체 차원에서 동일하겠지만 NT 계열에서는 한 프로세스 내부에서만 동일할 것이다.

하지만 내가 필요한 건 파일의 내용이 아니라 그냥 두 파일의 동등성뿐인데 이렇게 매핑을 하는 건 overkill 삽질 같다는 인상을 지울 수 없었다.
비슷한 예로, LoadLibrary도 같은 파일에 대해서는 같은 리턴값이 돌아온다. HMODULE도 오늘날은 핸들이 아니라 메모리 map된 주소이니까.. 다만, 오버헤드를 줄인답시고 LoadLibraryEx + LOAD_LIBRARY_AS_DATAFILE 이렇게 열어서는 안 된다. 그러면 로딩 방식이 크게 달라지더라.

3. JNI에서 문자열 처리하기

Java 언어는 JNI라고 해서 자기네 바이트코드 가상 머신이 아닌 C/C++ 네이티브 코드를 호출하는 통로를 제공한다.
프로그램 자체를 C/C++로 짜던 시절에는 극한의 성능을 짜내야 하는 부분에 어셈블리어를 집어넣는 게 관행이었는데.. 이제는 일반적인 코딩은 garbage collector까지 있는 상위 계층에서 수행하고, 극한의 성능을 짜내야 하는 부분에서만 C/C++ 코드를 호출한다는 게 흥미롭다.

JNI는 그냥 언어 스펙에 가까운 광범위한 물건이다. Windows 환경에서는 그냥 Visual C++로 빌드한 DLL이 export하는 함수를 그대로 연결할 수도 있다. 물론 그 DLL을 빌드하기 위해서는 Java SDK에서 제공하는 jni 인터페이스 헤더와 static 라이브러리를 사용해야 한다.
한편, 안드로이드 앱 개발에서 쓰이는 NDK는 JNI 스펙을 기반으로 자체적인 C++ 컴파일러까지 갖춘 네이티브 코드 빌드 도구이다.

Java의 문자열은 JNI에서는 jstring이라고 내부 구조를 알 수 없는 자료형의 포인터 형태로 전달된다. C++에서는 UTF-8과 UTF-16 중 편한 형태로 바꿔서 참조 가능하다.
UTF-8로 열람하려면 JNIEnv::GetStringUTFChars를 호출하면 된다. 길이를 알아 오려면 GetStringUTFLength부터 호출한다. 전해받은 문자열 포인터는 ReleaseStringUTFChars로 해제한다.

그 반면, UTF-16 형태로 열람하려면 위의 함수 명칭에서 UTF를 빼면 된다. GetStringChars, GetStringLength, ReleaseStringChars의 순이다. Java가 내부적으로 문자를 2바이트 단위로 처리하기 때문에 이들이 주로 취급하는 자료형은 jchar*이다. 그러니 얘는 char16_t 자료형과 호환된다고 간주해도 좋다. 참고로 wchar_t는 NDK 컴파일러의 경우 4바이트로 처리되더라.

UTF-16이나 UTF-8이나 다 UTF이긴 마찬가지인데, Java는 변별 요소인 8을 생략하고 함수 이름을 왜 저렇게 지었나 개인적으로 의구심이 든다. 물론 GetStringChars는 Java가 내부적으로 문자열을 원래부터 2바이트 단위로 처리하다 보니 우연히 UTF-16과 대응하게 됐을 뿐, 대놓고 UTF-16을 표방했던 건 아닐 것이다. 뭐, 이제 와서 그 체계를 바꾸는 건 불가능하고 "자바 문자열 = 2바이트 단위"는 완전히 고정되고 정착했지만 말이다.

또한 GetStringChars는 GetStringUTFChars와 달리 굉장히 치명적으로 불편한 단점이 하나 있다. 바로.. 변환된 문자열이 NULL-terminated라는 보장이 없다는 것이다!
그래서 본인은 이 포인터를 사용할 때 메모리를 n+1글자만치 또 할당해서 null문자를 추가해 주는 매우 번거로운 두벌일을 하고, 아예 클래스를 이렇게 따로 만들어야 했다. 좀 개선의 여지가 없으려나 모르겠다.

class CJstrToString16 {
    JNIEnv *_ev;
    jstring _jstr;
    const jchar *_ret;
    char16_t *_arr;
public:
    CJstrToString16(JNIEnv *ev, jstring js): _ev(ev), _jstr(js) {
        jsize n = ev->GetStringLength(js);
        _ret = ev->GetStringChars(js, NULL);
        _arr = new char16_t[n+1];
        memcpy(_arr, _ret, n*sizeof(char16_t));
        _arr[n] = 0; //고작 요거 하나 때문에..
    }
    ~CJstrToString16() {
        ev->ReleaseStringChars(_jstr, _ret);
        delete[] _arr;
    }
    operator const char16_t*() const { return _arr; }
};

4. Visual C++의 STL

C++은 타 프로그래밍 언어들과 달리, 심지어 전신인 C와도 달리, 언어가 개발되고 나서 자신의 특성을 잘 살린 라이브러리가 언어 차원에서 붙박이로 곧장 제정되지 않았던 모양이다. 그래서 각 컴파일러들이 중구난방으로 파편화된 형태로 라이브러리를 제공해 오다가.. 표준화라는 게 1990년대 말이 돼서야 논의되기 시작했다.

템플릿이 추가되어 C++에서도 제네릭, 메타프로그래밍이라는 게 가능해진 뒤부터 말이다. 처음에는 자료구조 컨테이너 위주로 STL이라는 이름이 붙었다가 나중에는 그냥 C++ library가 된 걸로 본인은 알고 있다.

Windows용으로 가장 대중적인 C++ 컴파일러야 두 말할 나위 없이 MS Visual C++이다. 얘는 거의 20여 년 전 6.0 시절부터 P.J. Plauger라는 사람이 구현한 C++ 라이브러리를 제공해 왔다. C 라이브러리와 달리 C++ 라이브러리는 소스가 비교도 안 될 정도로 복잡하고 난해하다는 것(암호 같은 템플릿 인자들..=_=), 그리고 저렇게 마소 직원이 아닌 개인 이름이 붙어 있다는 게 인상적이었다. 2000년대 초까지만 해도 휴렛-패커드라는 회사명도 주석에 기재돼 있었다.

P.J. Plauger는 현재는 Dinkumware라고 C++ 라이브러리만 전문적으로 관리하고 라이선스 하는 회사를 설립해 있다. 나이도 생각보다 지긋한 듯..
그런데 이런 세계적인 제품에 들어가는 라이브러리가.. 성능이 의외로 시원찮은가 보다. Visual C++이 제공하는 컨테이너 클래스가 유난히도 느리다고 까이는 걸 여러 사이트에서 봐 왔다.

최근에는 본인 직장의 상사마저도 같은 말씀을 하시기에 "헐~!" 했다. 업무상 필요해서 string, set, map 등을 써서 수십, 수백 MB에 달하는 문자열을 분석하는 프로그램을 돌렸는데, 자료 용량이 커질수록 속도가 급격히 느려져서 자료구조를 직접 새로 짜야 할 판이라고 한다.

난 개인적으로는 C++ 라이브러리를 거의 사용하지 않고, 더구나 그걸로 그 정도까지 대용량 작업도 해 보지 않아서 잘 모르겠다. 그 날고 기는 전문가가 만든 코드에 설마 그런 결함이 있으려나? 아니면 컴파일러의 최적화 문제인지?

글쎄.. 이런 게 있을 수는 있다. MFC의 CString은 그냥 포인터와 크기가 동일하며 값으로 전할 때의 reference counting도 처리한다. 그러나 std::string은 자주 쓰이는 짧은 문자열을 번거로운 heap 메모리 할당 없이 빠르게 취급하기 위한 배열까지 내부에 포함하고 있다. 이런 특성을 모르고 std::string도 함수에다 매번 value로 전달하면 성능에 악영향을 줄 수밖에 없다.

그런 식으로 임시 객체가 쓸데없이 생겼다가 사라지는 구조적인 비효율이 C++ 라이브러리에 좀 있는 걸로 들었다. R-value 참조자 &&가 도입된 것도 vector의 내부 처리에서 그런 삽질을 예방하는 근거를 언어 차원에서 마련하기 위해서라지 않는가? 그리고 Visual C++이 그런 비효율을 보정하는 성능이 좀 시원찮다거나 한 것 같다. 전부 다 그냥 추측일 뿐이다.

그러고 보니 cout<<"Hello world"가 printf("Hello world")보다 코드 오버헤드가 작아지는 날이 과연 올지 모르겠다. 이것도 그냥 떡밥인 건지..?? =_=;;

Posted by 사무엘

2019/04/09 08:32 2019/04/09 08:32
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1606

« Previous : 1 : ... 2 : 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : ... 23 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

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

Site Stats

Total hits:
2679187
Today:
1271
Yesterday:
2484