« Previous : 1 : ... 5 : 6 : 7 : 8 : 9 : 10 : 11 : 12 : 13 : ... 31 : Next »

컴퓨터 프로그램은 실행되는 과정에서 단순히 주메모리만 읽고 쓰는 게 아니라, 디스크처럼 기록이 영구적으로 남는 보조 기억 장치에다가도 정보를 기록한다.
여기에 기록되고 보관되는 것은 단순히 사용자가 직접 만들어 내서 Save라는 명령을 내려서 저장하는 문서 데이터만이 전부가 아니다. 사용자가 지정한 프로그램 자체의 각종 옵션· 설정값도 저장되어서 나중에 이 프로그램을 다시 시작할 때 그대로 보존되곤 한다. 세심한 프로그램이라면 직전에 프로그램 창을 띄워 놨던 크기와 위치 같은 시시콜콜한 정보도 몽땅 다 기억해 놓는다.

이런 '설정 정보'를 저장하기 위해 프로그램들은 예로부터 자신만의 cfg 내지 config.dat 같은 파일을 만들어 두곤 했다. 본인이 개발한 날개셋 한글 입력기도 imeconf.dat라는 파일을 운영체제의 사용자 계정별로 고정된 디렉터리에다 만들어 놓는다. (물론 날개셋 프로그램의 경우.. 입력 설정이라는 게 단순한 프로그램 setting 수준을 넘어 그 자체가 이미 사용자가 새로 창조하는 '문서 데이터'라는 성격도 지닌다만...)

Windows에서는 이런 설정 파일을 저장하고 불러오는 절차를 정형화하기 위해서 ini(초기화 정보)라는 텍스트 파일 포맷을 도입했으며, 이걸 읽고 쓰는 [Get/Write][Private]Profile[Int/String]이라는 API 함수를 제공해 왔다. [섹션] 구분과 함께 "이름=값" 이런 게 주욱 들어가 있는 그 파일 말이다.

이들 함수는 파일을 읽고 씀에도 불구하고 뭔가 열어서 핸들값을 얻었다가 나중에 닫는 절차가 없는 게 특징이었다. 구현하는 사람 입장에서는 그리 좋은 설계 형태가 아닐 텐데..
Private이 안 붙은 API를 사용하면 자기 ini 말고 심지어 운영체제의 설정 파일인 win.ini에다가도 정보를 기록할 수 있었다.

ini 파일은 당장 사용하기는 간편하지만 한계와 문제점이 많았다. 가장 먼저 XML 같은 다단계 계층 구조를 지원하지 않았다(과거 Windows 3.x의 프로그램 관리자의 그룹이 계층 구조가 아니었던 것처럼).
그리고 아까 언급했듯이 핸들/상태 정보가 없는 API의 설계 형태에다가 텍스트 포맷이라는 점까지 겹쳐서 데이터가 방대해질 때의 처리 성능도 썩 좋지 않았다.

응용 프로그램이 있는 디렉터리, 또는 Windows 디렉터리에 온갖 자잘한 ini 파일들이 쌓이면서 지저분해지는 점 역시 문제였다. 그리고 저장을 할 거면 사용자 설정 데이터들 전용 디렉터리라도 마련해 놔야지, 그게 프로그램 실행 파일들이 있는 곳에 버젓이 저장되는 건 오늘날의 보안 내지 권한 관점에서 봤을 때 좋지 못한 설계 형태였다.

그래서 성능, 보안, 효율 등등을 모두 잡기 위해서 마소에서는 운영체제와 응용 프로그램들의 설정 저장은 파일 형태로 노출시킬 게 아니라 이를 전담하는 별도의 거대한 중앙집권 데이터베이스를 만들 생각을 하게 됐다. 그래서 이를 레지스트리라는 이름으로 일찍부터 도입했다.
마치 디렉터리 경로처럼 역슬래시로 구분된 계층 구조의 key를 만들고, 그 아래에 파일처럼 여러 개의 value들을 집어넣을 수 있게 했다.

물론 레지스트리는 구조적으로 장점도 있고 단점도 있다. 컴덕이나 프로그래머들 사이에서 호불호가 갈리며, 레지스트리를 아주 싫어하는 사람도 있다. macOS나 리눅스는 레지스트리 그딴 거 없이도 잘 돌아가고 더 안정적이기까지 하다고 주장한다.
하지만 레지스트리가 없다고 해서 그런 OS에 레지스트리가 하는 일 자체가 존재하지 않는 건 아니다. 그 OS에서도 운영체제나 응용 프로그램들이 자기 설정을 저장하는 공간이 있어야 할 텐데 도대체 어디에 저장되는 걸까?

프로그램을 제거한 뒤에도 그 프로그램이 써 놓은 레지스트리 데이터가 같이 지워지지 않아서 레지스트리가 갈수록 지저분해지고 통제불능이 된다는 식의 비판이 있다. 그런데 그건 ini/cfg 파일을 쌩으로 다룰 때도 부주의하면 어차피 똑같이 발생할 수 있는 문제이다. 한쪽에서 존재하는 문제는 대부분 다른쪽에서도 형태만 바뀐 채로 고스란히 존재할 수밖에 없다. 본인은 이런 논리를 근거로 레지스트리 무용론 급의 회의적인 시각에는 동의하지 않는다.

프로그램마다 설정 데이터라는 게 수십~수천 바이트, 정말 커 봤자 수만 바이트 남짓할 아주 작은 분량에 지나지 않는다. 그런 자잘한 데이터를 한데 관리해 주는 DB가 운영체제 차원에서 제공되면 편하면 편하지 상황이 더 나쁘지는 않을 것이다.

레지스트리는 딱 32비트 Windows 95/NT와 함께 등장했다. 이때부터 마소에서는 소프트웨어 개발자들로 하여금 구닥다리 INI 파일 함수를 쓰지 말고 ADVAPI32.DLL에 있는 Reg** 레지스트리 조작 함수를 사용할 것을 적극 권해 왔다.
다만, 이들 함수는 구닥다리 함수보다 받아들이는 인자가 많고 사용하기가 번거롭고 귀찮긴 하다. 마치 fopen과 CreateFile의 차이만큼이나 말이다.

그래서 별도의 클래스를 만들어서 쓰면 편하다. HKEY를 멤버로 가지면서 소멸자에서는 RegCloseKey를 해 주고, 각종 타입별로 인자를 다양하게 오버로딩한 Get/Set 함수를 만들고 말이다. 레지스트리 관련 API는 MFC에서도 의외로 클래스화를 전혀 하지 않았으니 이거 연구는 프로그래머들 개인 재량이다. MFC는 16비트 시절부터 있던 CWinApp 클래스의 [Get/Write]Profile* 함수를 상황에 따라 ini 대신 레지스트리 기록으로 대신하도록 동작을 확장했을 뿐이다.

사실, 16비트 Windows 시절에는 지금처럼 HKEY_* 어쩌구 하는 그런 형태의 레지스트리는 없었지만, 그 전신 비스무리한 건 있었다. Windows 3.1에서 그 이름도 유명한 OLE라는 기능 내지 개념이 추가됐기 때문이다.
응용 프로그램들의 설정 같은 건 몰라도 확장자별 연결 프로그램이라든가 OLE 서버 같은 정보들은 운영체제 차원에서 관리하는 별도의 바이너리 데이터 파일에 등재되었다(Windows\reg.dat). 그리고 여기에 정보를 사용하는 Reg[Open/Create/Close]Key와 Reg[Query/Set]Value 같은 함수가 있었다.

16비트 시절부터 이런 초보적인 함수가 있었는데 이를 확장하여 오늘날의 레지스트리가 된 것이다. 그렇기 때문에 오늘날 사용되는 레지스트리 함수들은 대부분 뒤에 Ex가 추가돼 있다. 옛날에는 루트 키로 HKEY_CLASSES_ROOT 하나만이 존재했었다.
이 점을 생각하면 옛날에는 지금 같은 레지스트리가 있지도 않았는데 지금 왜 RegCreateKeyEx, RegQueryValueEx 등등 Ex 함수를 써야 하는지 이유를 알 수 있다.

지금까지 Windows의 레지스트리 개념과 관련 API를 살펴봤으니, 다음으로는 역대 버전별 레지스트리 편집기에 대해서 살펴보도록 하겠다.
레지스트리 편집기는 과거 도스 시절로 치면 디스크 내부 구조를 저수준에서 수정할 수 있는 노턴 유틸리티의 DiskEdit와도 비슷한 아주 저수준 유틸리티이다. 아예 없어서는 안 되지만 일상적으로 쓸 일은 없으며(없어야 하며), 초보자에 의한 섣부른 조작은 절대 권장되지 않는 위험한 프로그램이다. 이걸 조작함으로써 운영체제를 맛이 가게 만들고 컴퓨터 부팅조차 안 되게 만들 수도 있기 때문이다. 그러니 보조 프로그램 그룹에 정식으로 등재될 일도 없다.

우리가 흔히 알고 있는 레지스트리 편집기는 Windows 95 시절부터 지금까지 큰 변경 없이 이어져 오고 있는 탐색기(왼쪽에 트리, 오른쪽에 리스트 컨트롤) 비슷한 형태의 프로그램이다.

사용자 삽입 이미지

(1) Windows 9x에서는.. 레지스트리는 내부적으로 Windows\System.dat와 Windows\User.dat라는 두 파일에 저장되었다. 그리고 HKEY_DYN_DATA라는 predefined root key가 있어서 여기를 들여다보면 일부 시시각각 변하는 시스템 정보 같은 걸 얻을 수 있었다고 한다.

그리고 Windows 9x용 레지스트리 편집기는.. 단순히 This program cannot be run in DOS mode가 아니라 유효한 도스용 코드가 stub으로 같이 들어간, 다시 말해 DOS/Windows 겸용 프로그램이었다.
레지스트리에 이상이 생겨서 Windows 부팅이 안 되더라도 레지스트리의 백업과 복구 정도는 도스에서 할 수 있게 하기 위해서이다. regedit.exe를 순수 도스에서 실행하면 놀랍게도 이런 옵션 안내를 볼 수 있다.

사용자 삽입 이미지

마소에서 프로그램을 이런 식으로 특수하게 빌드한 예는 극히 드물다. 이는 레지스트리 편집기가 그만치 특수한 프로그램임을 의미한다.

(2) 그럼 Windows NT로 넘어가기 전에, 더 옛날 Windows 3.x를 잠시 살펴보도록 하겠다.
이때도 레지스트리의 전신이 있었고 비스무리한 API도 있었듯이, regedit.exe라는 프로그램 자체는 있었다.
하지만 얘 역시 그룹에 정식으로 등록되어 있지는 않았으며, 이때 registry란 보다시피 그냥 확장자 별 연결 프로그램과 관련 DDE 명령.. 이런 것이 전부였다. 아까 언급했던 Windows\reg.dat의 내용을 편집한다.

사용자 삽입 이미지

저 때는 Properties도 '등록정보'라고 번역하고 Registry도 '등록 정보'라고 번역했다니.. 참 므흣하다. 띄어쓰기 하나 차이밖에 없다.
Windows 95부터는 확장자 연결 설정은 그냥 탐색기의 옵션에서 별도의 탭으로 들어가 있다. 그리고 98을 넘어서 2000/XP즈음부터 Property의 한국 마소 공식 번역은 '속성'으로 완전히 바뀌어서 지금에 이르고 있다.
그리고 이 시절의 regedit.exe에는 95의 것과 같은 유의미한 도스 stub이 들어가 있지도 않았다.

(3) 이제 오늘날 사용되고 있는 NT 계열 차례이다.
일단, 레지스트리가 저장되는 파일은 Windows\system32\config에 있는 default, software, system, components 같은 확장자 없는 파일들이다. 크기가 다들 수십 MB씩 한다.
그리고 각 사용자별 정보는 사용자 계정의 루트에 있는 ntuser.dat 파일이다.
9x와 NT 계열이 파일 포맷이 동일한지는 모르겠다. 지금의 NT 계열은 도스 부팅 기능이 없고, 운영체제가 가동 중일 때는 이들 파일들이 언제나 열리고 잠겨 있어서 일반 프로그램이 레지스트리를 파일 차원에서 들여다볼 수가 없다.

Windows NT 계열은 HKEY_PERFORMANCE_DATA라는 전용 root key가 있다. HKEY_DYN_DATA처럼 실제 레지스트리 데이터는 아니지만 레지스트리 API를 통해 시스템 정보를 얻어 오는 용도인데.. 이것도 비슷한 기능이 9x와 NT 계열의 구현이 서로 파편화된 경우가 아닌가 싶다. 로드된 프로세스/DLL 정보를 얻는 API만 해도 과거에는 두 계열이 서로 달랐으니 말이다.

그리고 Windows NT는 초창기 3.1 시절부터 지금과 같은 형태의 레지스트리를 갖고 있었기 때문에 자체적인 레지스트리 편집기도 보유하고 있었다. 바로 regedt32.exe이다.

사용자 삽입 이미지

얘는 만들어진 시기가 시기이다 보니, 탐색기가 아니라 구닥다리 파일 관리자 스타일로 만들어져 있다. 왼쪽의 계층 트리와 오른쪽의 값 목록은 모두 재래식 리스트 컨트롤 기반이다. 또한, 루트별로 제각각 다른 레지스트리 창들이 MDI 형태로 구성되어 있다.
오늘날 쓰이는 공용 컨트롤 기반 regedit.exe는 바로 regedt32.exe를 베껴서 새로 만들어진 거나 마찬가지이다.

Windows 2000, 그리고 확인은 안 해 봤지만 NT4에는 regedit와 regedt32가 같이 들어있었다. 기능이 대등하긴 하지만 regedit는 오리지널 NT용 유틸리티에 있던 '레지스트리 하이브'를 통째로 불러들이는 기능이 없었다.
그러다가 regedit에 이 기능이 들어가서 regedt32를 완전히 대체하게 된 것은 Windows XP부터이다. NT의 regedt32를 보면 메뉴가 뭔가 많이 있는 것 같은데, 실제로 열어 보면 별 거 아니다.

Windows NT의 레지스트리 편집기는 9x의 것과 달리 값의 이름과 데이터뿐만 아니라 REG_SZ, REG_DWORD 같은 타입도 표시해 줬다. 9x에서는 어려운 전문 용어라고 일부러 뺐던 것 같다.
그리고 이런 새 GUI 기반의 레지스트리 편집기는 오랫동안 REG_MULTI_SZ라고 0으로 구분된 복수 문자열을 편집하는 기능이 없어서 그냥 바이너리 에디터 창이 떴었다. 그러다가 regedt32와 기능이 통합된 Windows XP에서부터 한 줄에 하나씩 복수 문자열을 편집하는 기능이 도입되었다.

이렇듯, regedit와 관련하여 Windows 3.1, NT 3, 9x 등.. 복잡한 사연이 많은 걸 알 수 있다.
이 프로그램에는 레지스트리의 일부 구간을 별도의 파일로 저장하거나 도로 불러오는 기능이 응당 들어있는데, reg 파일은 아이러니하게도 key 이름이 []로 둘러싸이고 값들이 a=b 이런 식으로 쓰인 게 마치 과거의 ini와 형태가 비슷하다.

한번 만들어진 뒤에 딱히 기능이 바뀔 일이 별로 없는 프로그램이다만.. Windows 2000부터는 reg 파일의 인코딩이 UTF-16으로 바뀌었다. 그리고 Windows 10 어느 업데이트부터는 현재의 레지스트리 주소(key 이름)이 아래의 상태 표시줄 대신에 위의 주소 표시줄에 표시되고, 사용자가 거기에 인터넷 주소 치듯이 수동으로 입력을 해서 원하는 key로 바로 찾아갈 수도 있게 UI가 편리해졌다. 나름 굉장히 바람직한 개편이다.

※ 부록: 레지스트리 편집기에 준하는 위상의 자매품

1. sysedit

과거 Windows 3.x 시절에는 sysedit라는 이름으로 config.sys, autoexec.bat, win.ini, system.ini라는 4대 시스템 설정 파일만 한데 모아서 편집할 수 있는 MDI 텍스트 에디터가 있었다. 이게 Windows 9x는 말할 것도 없고 NT 계열 제품에도 32비트 한정으로 포함돼 있었다.
Windows에 내장돼 있는 극소수의 16비트 프로그램이기 때문에 날개셋 한글 입력기를 테스트 할 때도 개인적으로 유용하게 사용했다.

사용자 삽입 이미지

물론 Windows 9x는 config.sys와 autoexec.bat를 거의 퇴출시켰고, NT 계열은 뒤이어 두 ini 파일까지 완전히 퇴출시킨 거나 마찬가지다.

2. msconfig

Windows 98부터 잘 알려지지 않았지만 꽤 유용한 유틸리티가 추가됐다.
얘는 9x용은 sysedit가 하는 일을 모두 포함하면서(해당 파일에 내용을 추가하거나 기존 내용을 간편하게 제거), 레지스트리에 등록돼 있는 시작 프로그램들의 실행 여부를 제어하고, 각종 시스템 관리 유틸리티도 실행하는 기능을 제공했다.

사용자 삽입 이미지

이 프로그램은 레지스트리 편집기와 마찬가지로 오늘날까지도 남아 있기 때문에 명령 프롬프트에서 실행 가능하다.

Posted by 사무엘

2018/11/22 08:33 2018/11/22 08:33
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1557

우리는 객체지향 프로그래밍 언어를 공부하면서 클래스 멤버들의 공개 등급이라는 개념을 접한다. 까놓고 말해 public, protected, private은 C++, C#, Java에 모두 공통으로 거의 동일한 용도로 쓰이는 키워드이다.

C++은 이런 공개 등급을 마치 case나 default처럼 뒤에다 콜론을 붙여 일종의 label 형태로 지정하지만, Java/C#은 멤버 함수 및 변수의 선언에 공개 등급이 같이 붙는다. 이 세 등급의 차이는 아래 표와 같다.

출처 \ 공개 등급 public protected private
외부 O X X
파생 클래스 내부 O O X
자기 클래스 내부 O O O

public이야 멤버의 접근에 아무 제약이 없는 등급이지만 pr*로 시작하는 나머지 두 등급은 그렇지 않다. 단지, protected는 파생 클래스의 내부에서 자기에게 접근을 허용하는 반면, private은 이마저도 허용하지 않는다. 상속 관계에서부터 둘의 차이가 발생하는 것이다. private은 사람으로 치면 자식에게도 물려주거나 공유하지 않는 개인 칫솔, 속옷, 복용하는 약 같은 급의 지극히 '사쩍'인 물건에다 비유할 수 있겠다.

물론 그 어떤 공개 등급이라도 자기가 선언해 놓고 자기 클래스의 멤버 함수에서도 사용하지 못하는 등급은 없다. 심지어 자기 자신 this뿐만 아니라 자기 클래스에 속하는 아무 객체라도 말이다.
즉, 아래의 코드에서 bar(함수)와 priv_member(변수)가 똑같이 Foo의 멤버라면, bar는 o라는 다른 인스턴스의 priv_member에도 저렇게 마음대로 접근할 수 있다.

void Foo::bar(Foo& o)
{
    priv_member += o.priv_member;
}

this 포인터가 아예 없는 static 멤버 함수도 있다는 점을 생각한다면 이는 당연한 귀결이라 하겠다. 선언은 자기가 했지만, 접근과 사용은 내가 곧장 못 하고 파생 클래스에서만 가능하다거나 하는 기괴한 개념은 없다.

오히려 반대로 부모 클래스에서는 공개였는데 파생 클래스에서는 부모의 멤버를 감추고, 외부에서는 오로지 파생 클래스가 새로 제공하는 public 멤버만 사용 가능하도록 클래스를 더 폐쇄적인 형태로 바꾸는 건 가능하다.

C++에서는 클래스를 상속할 때 별 생각 없이 파생 클래스의 이름 뒤에다 콜론을 찍고 public Base1, public Base2 이런 식으로 public을 붙이곤 한다. 이때 써 주는 공개 등급은.. 부모 클래스 멤버들의 공개 등급이 파생 클래스에서는 어떻게 되는지를 결정한다. 그 방식은 한편으로는 직관적이어 보이면서도 한편으로는 헷갈리고 어렵다.

상속 방식 \ 기존 공개 등급 public protected private
public public protected private
protected protected protected private
private private private private

public 상속은 부모 클래스 멤버들의 공개 등급을 파생 클래스에다가도 원래 지정되었던 형태 그대로 유지시킨다. 부모 때 public이었던 놈은 파생에서도 public, protected는 protected.. 그런 식이다.

그 반면, protected나 private 상속으로 가면 일단 부모 멤버들은 몽땅 private, 또는 잘해야 protected로 등급이 바뀐다. public 속성이 없어지기 때문에 외부에서 파생 클래스 객체를 대상으로 부모 클래스의 멤버에 접근은 할 수 없어진다. public, protected, private이라는 공개 등급을 각각 3, 2, 1이라는 수라고 생각한다면, 클래스를 상속하는 방식 N은 부모 멤버들의 공개 등급을 N보다 크지 않은 등급으로 재설정하는 셈이다.

다른 예로, 부모 클래스에서 protected였던 멤버가 있는데 자식이 부모를 private로 상속했다고 치자. 그러면 그 멤버는 부모에서의 공개 등급은 protected였기 때문에 자식 클래스의 내부에서 마음대로 접근이 가능하다. (참고로 public 멤버는 부모에서 public이었다 하더라도 non-public 상속을 거치면 파생 클래스에서의 외부 접근이 곧장 차단된다. 내부 접근과 외부 접근의 차단 시기가 서로 차이가 있다.)

하지만 private 상속된 protected 멤버는 파생 클래스에서의 등급이 private로 바뀌었다. 그렇기 때문에 얘로부터 상속받은 손자 클래스에서는 이 멤버에 더 접근할 수 없게 된다.
그리고 한번 private/protected 상속으로 인해 작아져 버린 공개 등급은 후속 파생 클래스가 다시 public으로 상속한다고 해서 다시 커지지 않는다. 상속 과정에서 기존 공개 등급을 동일하게 유지하거나 더 낮출 수는 있어도 도로 높일 수는 없다는 뜻이다.

부모 클래스에서 private 상태였던 멤버들은(처음부터 그리 됐든, 상속 방식 때문에 그리 됐든..) public 등 그 어떤 방식으로 상속을 받더라도 파생 클래스에서는 결코 접근할 수 없다. 위의 표에서 그냥 평범하게 private라고 명시된 놈은 현재 private이기 때문에 다음 파생 클래스에서는 감춰진다는 뜻이며, 취소선이 그어져 있는 private은 이미 접근 불가이고 파생 클래스의 입장에서는 그냥 없는 멤버가 됐음을 의미한다.

C++은 언어 차원에서 POD(단순 데이터 더미)와 객체(생성자 소멸자 가상 함수 등..)의 구분이 모호하다 보니, struct와 class의 언어적 구분도 없다시피한 것으로 잘 알려져 있다. 그냥 디폴트로 지정돼 있는 멤버의 공개 등급이 전자는 public이고 후자는 private이라는 차이만이 있을 뿐이다.

그것처럼, 클래스를 상속할 때 공개 등급을 안 쓰고 그냥 class Derived: Base {} 라고만 쓰면 Base는 private 방식으로 상속된다. C++의 세계에서는 디폴트 공개 등급이 어디서든 private인 셈이다.

class 대신 아예 struct Base { private: int a; } 이런 식으로 클래스를 선언해도 된다. 미관상 보기 좋지 않으니까 안 할 뿐이지.
얘는 클래스 선언 문맥에서는 struct라는 대체제가 있고, 템플릿 인자 문맥에서는 typename라는 대체제가 있으니 위상이 뭔가 애매해 보인다. 전자는 C 시절부터 있었던 키워드요, 후자는 C++98에서 추가된 키워드이다.

코딩을 하다 보면 범용적인 부모 클래스의 포인터로부터 자식 클래스로 static_cast 형변환을 하는 경우가 많다. 파생 클래스가 부모보다 멤버가 더 많고 할 수 있는 일도 더 많기 때문이다. 그런데 protected/private 상속을 한 파생 클래스는 자기만의 새로운 멤버/메소드가 있는 한편으로 부모에게 가능한 조작이 금지되어 버린다.

일반적으로 부모와 자식 클래스 관계는 is-a 관계라고 일컬어지고, 자식 포인터에서 부모 포인터로 형변환은 "당연히" 가능한 것으로 여겨지는데.. 그 당연한 건 public 상속일 때만으로 한정이다. 비공개 상속일 때는 정보 은닉을 제대로 보장하기 위해 자식에서 부모로 가는 게 허용되지 않으며, 심지어 static_cast로도 안 된다! Visual C++ 기준 C2243이라는 고유한 에러까지 난다.

기술적으로는, 객체 내부의 ABI 차원에서는 아무 위험할 것이 없음에도 불구하고 전적으로 객체지향 이념에 위배된다는 이유만으로 자식에서 부모로 못 간다. 무리해서 강제로 부모인 것처럼 취급하려면 reinterpret_cast라는 무리수를 동원해야 한다.

게다가 C++은 다중 상속(=가상 상속) 때문에 일이 더 크고 복잡해진다.

class A { public: int pub_mem; };

class B: virtual protected A {};

class C: virtual public A {};

class D: public B, private C {};

D o; o.pub_mem = 100;

위의 코드에서 저 멤버에다가 100을 대입하는 것은 가능할까?
이걸 판단하기 위해서는 컴파일러는 클래스 D의 가능한 상속 계통을 모두 순회하면서 최대 공개 등급(?)이 public이 되는지 따져 봐야 한다.
(참고로, 둘을 virtual로 상속하지 않았다면, 저 pub_mem이 B에 딸린 놈인지 C에 딸린 놈인지 알 수 없어서 모호하다고 어차피 컴파일 에러가 남..)

B는 A를 protected 상속했기 때문에 여기서 pub_mem이 protected가 되어 버려서 나가리. C는 A를 public으로 상속했지만, 최종 단계인 D에서 C를 private 상속해 버렸기 때문에 private가 된다.
요컨대 A에서 D까지 가는 동안 public이 계속 public으로 유지되는 상속 계통이 존재하지 않으며, 최대 공개 등급은 B 계열인 protected로 귀착된다. 그렇기 때문에 위의 구문은 컴파일 에러가 나게 되며, pub_mem은 D의 멤버 함수 내부에서나 건드릴 수 있다.

Visual C++의 경우, 가상 상속이 아닌 일반적인 상속 공개 등급 위반이라면 C2247 Not accessible because .. uses ... to inherit from 이라는 좀 단정적인 문구의 에러가 뜬다.
그러나 가상 상속 체계에서는 "접근 경로가 없다"라는 표현이 들어간 C2249 No accessible path to ... member declared in virtual base ... 에러가 난다. 공개 등급 체크를 위해서 더 복잡한 계산을 한 뒤에 판정된 에러이기 때문에 그렇다. 매우 흥미로운 차이점이다.

아무튼.. 참 복잡하기 그지없다.
그래서 C++ 이후의 언어들은 상속은 그냥 부모 멤버들의 공개 등급을 그대로 유지하는 public 상속만 생각하는 편이다. 다중 상속을 봉인해 버렸다는 건 더 말하면 입만 아플 것이고. C++이 객체지향 언어로서 여러 실험과 시행착오를 많이 했고 거기서 너무 무리수로 여겨지는 개념들이 후대의 언어에서는 짤렸다고 생각하면 되겠다.

Posted by 사무엘

2018/10/31 08:36 2018/10/31 08:36
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1549

Windows에서 돌아가는 GUI 프로그램은 커다란 자기 창을 띄우며, 그러면 작업 표시줄(task bar)에서도 그 창이 있다는 걸 표시해 준다.
그런데 작업 표시줄은 어떤 프로세스가 생성하는 여러 창들을 어떤 기준으로 선별하여 표시하는 걸까? 화면에만 표시되고 작업 표시줄에는 나타나지 않는 일명 '스텔스 윈도우'는 어떻게 만드는 걸까?

심지어 이 작업 표시줄에 등재된 창 목록은 Alt+tab을 눌렀을 때 나타나는 task 목록과도 완전히 일대일 대응하지는 않는 것 같다. 그 관계는 어떻게 될까?

일단, 작업 표시줄은 (1) child가 아니고(overlapped 또는 popup) owner(parent)가 NULL인 모든 윈도우를 자기 목록에 등재해서 표시해 준다. 대화상자건, 응용 프로그램이 클래스를 따로 등록한 윈도우건 모두 상관없다. 이건 대부분의 상황에서 충분히 합리적인 조치이다.

옛날에 대략 Windows XP 정도까지 쓰던 기억을 떠올려 보면, 디스플레이/키보드/마우스 같은 제어판 애플릿들은 분명 rundll32라는 독립된 프로세스로부터 구동된 대화상자임에도 불구하고 작업 표시줄에는 제목이 뜨지 않았다. 그 이유는 제어판 애플릿은 제어판 셸로부터 주어진 보이지 않는 부모 윈도우를 owner로 삼고 생성되기 때문이다.

그러므로 보이지 않는 윈도우를 생성하여 owner로 잡으면 대화상자뿐만 아니라 크기 조절 가능한 멀쩡한 overlapped 윈도우라도 작업 표시줄에 표시되지 않는 '스텔스' 형태로 만들 수 있다. 일반적으로 그런 건 만들 필요가 없고 그게 UI 디자인 상으로 권장되는 짓도 아니니 안 할 뿐이다.
그리고 저런 모델은 응용 프로그램의 입장에서는 자기 창이 하나가 아니라 두 개가 존재하는 셈이므로 창을 관리하는 게 약간 더 귀찮아진다.

한편, 위의 (1) 다음으로 몇 가지 단서가 있다. (2) WS_EX_TOOLWINDOW는 owner가 NULL이더라도 이 창이 무조건 작업 표시줄에 등록되지 않게 하고 심지어 Alt+tab 작업 목록에도 나타나지 않게 한다.
얘는 덤으로 제목 표시줄도 더 얇게 찍히게 한다(WS_CAPTION이 명시된 경우). 그래픽 에디터의 도구 팔레트처럼 작고 키보드 포커스도 안 받고, 화면에 언제나 표시되어 있는 그런 창을 찍으라고 있는 스타일인 것이다.

이 스타일 한 방이면 스텔스 윈도우를 아주 쉽게 만들 수 있다. 단지 제목 표시줄이 꼭 필요하고 그걸 다른 평범한 윈도우처럼 두껍게 만들고 싶을 때에만 owner 편법을 쓰면 될 듯하다.

그리고 다음으로.. WS_EX_TOOLWINDOW와 상극인 WS_EX_APPWINDOW 스타일이 있다. (3) 얘는 자기가 owner가 지정되었다 하더라도 반드시 작업 표시줄에 표시되게 한다.
Windows Vista인가 7부터는 디자인이 바뀌었는지 제어판 애플릿이 작업 표시줄에 나타나는 걸 볼 수 있다. 얘들은 여전히 owner 윈도우가 따로 있음에도 불구하고 저 스타일이 지정되었기 때문에 작업 표시줄에도 보인다. 중간에 뭔가 디자인 정책이 바뀐 듯하다.

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

내가 표시하는 대화상자나 창이 작업 표시줄을 건드리지 않고 조용히 떴다가 없어질지, 아니면 독립된 응용 프로그램처럼 뜰지 결정하는 것은 개발자의 재량이다. 다만, 작업 표시줄에다가도 나타나게 할 거면 창에다가 적절한 아이콘도 넣어 주는 게 좋을 것이다.

하지만 어지간해서는 owner가 NULL인 것만으로도 작업 표시줄에 창이 자동으로 등록되니 이 스타일이 굳이 따로 쓰일 일은 내 경험상 별로 없다.
WS_OVERLAPPEDWINDOW나 WS_POPUPWINDOW는 여러 기존 스타일들의 조합이지만 WS_EX_*WINDOW는 그렇지 않고 자신만의 고유한 값이다.

그리고 마지막으로 하나 살펴볼 게 있다.

MS Office Excel의 경우, 워크시트 문서창이 응용 프로그램 창의 내부에 소속된 child이다. 단독의 popup 윈도우 같은 게 아니다.
그럼에도 불구하고 한 프로그램에서 여러 파일을 열면 그 문서창들이 작업 표시줄에 제각각 나타난다. 이게 먼 옛날 Office 2000쯤부터 그렇게 되기 시작했는데.. 어떻게 이런 일이 가능한지 신기하지 않으신가?

이건 윈도우 스타일 조작만으로 가능한 동작이 아니고 별도의 API를 사용해서 구현한다.
셸 API들, 특히 작업 표시줄 근처에 있는 트레이(notification) 아이콘을 조작하는 API는 다 SH_*로 시작하는 고전적인 C 함수인 반면, 작업 표시줄을 조작하는 API는 ITaskbarList라는 COM 인터페이스 형태이다.

ITaskbarList를 얻어 온 뒤 HrInit를 호출해서 초기화하고, MDI 문서 창이 생성되면 자기 자신에 대해 AddTab + ActivateTab을 호출한다(ActiveTab도 반드시 해 줘야 됐음). 그리고 문서 창이 닫힐 때는 DeleteTab를 하면 된다. 이렇게만 하면 당장 날개셋 편집기조차도 얼추 Excel처럼 문서 창을 작업 표시줄에서 곧장 접근 가능하게 수정할 수 있다.

사용자 삽입 이미지

다만, 일이 마냥 간단하지만은 않다. 문서 창뿐만 아니라 기존 날개셋 편집기 자체의 창이 등록된 것도 같이 관리해야 하기 때문이다.
문서 창이 없거나 하나밖에 없으면 그냥 프로그램 자체의 창 하나만 유지하고 있고, 문서 창이 2개 이상이 되면 그때부터 프로그램 창은 날리고 문서 창들이 작업 표시줄에 나타나게 해야 한다. 불가능한 일은 아니지만 자동화가 돼 있지 않아 귀찮다. 마치 클립보드 viewer chain을 관리하는 것처럼 말이다.

요컨대, owner 윈도우, WS_EX_TOOL/APPWINDOW 스타일, 그리고 ITaskBarList 인터페이스만 기억하고 있으면 창과 작업 표시줄의 연계는 다 마스터했다고 볼 수 있겠다.
참고로 ITaskBarList는 초창기에는 이렇게 탭을 수동으로 등록하고 삭제하는 원시적인 기능만 있었다. 아마 IE 4나 5 시기쯤, 웹 브라우저와 운영체제 셸이 얽혀서 마개조되고 DLL hell 현상이 악명을 떨치던 20여년 전에 첫 등장했다.

그러다가 얘가 온갖 기능이 추가되어 ITaskBarList3으로 발전한 게 2000년대 말의 Windows 7 타이밍이다. 작업 표시줄에다가 응용 프로그램의 고유한 썸네일을 표시하고, 백그라운드 작업 진행률을 나타내고, 재생기의 경우 간단한 재생/멈춤 같은 버튼까지 갖다박는 UI 요소가 그때 추가됐기 때문이다. 그런 기능들을 바로 저 API를 통해 사용할 수 있다.

Posted by 사무엘

2018/10/17 08:34 2018/10/17 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1544

1. rc 파일의 유니코드화

Visual C++ 2008까지만 해도 안 그랬던 것 같은데.. 2010쯤부터는 새로 만드는 프로젝트들의 리소스 스크립트(*.rc) 파일의 기본 인코딩이 유니코드(UTF-16LE)로 바뀌었다는 걸 본인은 최근에야 알아차렸다. 어쩐지 구버전에서는 파일을 열지를 못하더라.
그러니 이런 rc 파일의 내부에는 #pragma code_page(949) 같은 구차한 지시문도 없다.

리소스는 Windows의 실행 파일 포맷 차원에서 유니코드인데, 그걸 생성하는 스크립트 파일은 왜 유니코드가 아닌지 본인은 오랫동안 의아하게 생각해 왔다. 물론, 다국어 리소스는 언어별로 다 따로 만들지, 한 리소스 내부에 갖가지 외국어가 섞여 들어갈 일은 거의 없기 때문에 이런 방식이 크게 문제 되지 않았을 뿐이다.

리소스 파일 관련 속성을 보면.. MFC 모드로 동작할지 말지를 지정하는 옵션이 있다. 이건 rc 파일 내부에 저장되는 추가적인 옵션/플래그 같은 건 아니고, 그냥 Visual C++ IDE의 동작 방식을 결정하는 것이다. 프로젝트 내부의 설정으로 저장되는 것 같다.

이 옵션의 지정 여부에 따라 대표적으로 달라지는 것은 초기에 콤보박스 내부에다 집어넣을 데이터 목록을 지정하는 기능이다. 이것은 Windows API가 자동으로 해 주는 게 아니라, MFC가 추가적으로 구현해 놓은 기능이다. 그렇기 때문에 리소스 파일이 MFC mode로 지정돼 있지 않으면 해당 기능을 사용할 수 없다.

리소스 파일을 들여다 본 분은 아시겠지만 이 초기화 데이터는 Dialog 리소스 템플릿 안에 내장돼 있는 게 아니라, 240 (RC_DLGINIT)이라는 custom 리소스 타입에 따로 들어있다.
할 거면 리스트박스에다가도 같은 기능을 넣어 줄 것이지 왜 하필 콤보박스에다가만 넣었는지는 잘 모르겠다.

굳이 MFC를 사용하는 프로젝트가 아니더라도, Visual C++ 리소스 에디터가 저장해 놓은 대로 콤보박스를 초기화하는 기능을 내 프로그램에다가 넣고 싶으면 MFC의 소스 코드를 참고해서 직접 구현하면 된다.

그런데 리소스 스크립트 전체의 포맷은 유니코드로 바뀌었음에도 불구하고, 이 초기화 데이터는 기존 코드/프로그램과의 호환성 문제 때문에 여전히 CP_ACP이니 참 애석하다.
MFC 소스를 보면 문자열을 CB_ADDSTRING 메시지로 등록하는 부분에서 의도적으로 SendDlgItemMessageA라고 A 버전을 호출한 것을 볼 수 있다.

Windows는 여러 모로 UTF-8과는 친화적이지 않은 게 느껴진다. "UTF-8 + 32비트 wchar_t"를 전혀 찾아볼 수 없는 환경이다.

2. 소스 코드의 유니코드화, UTF-8 지원

앞서 언급한 바와 같이, Windows는 유니코드 계열이건 그렇지 않은 계열이건 2바이트(...) 단위의 문자 인코딩을 굉장히 좋아해서 전통적으로 UTF-8에 친화적이지 않았다. 친화도는 "UTF-16 > BOM 있는 UTF-8 > BOM 없는 UTF-8"의 순이다.

물론 Visual Studio의 경우, 먼 옛날의 200x대부터 소스 코드를 UTF-8 방식으로 불러들이고 저장하고, 파일 형식을 자동 감지하는 것 자체는 잘 지원한다. 하지만 한글 같은 게 전혀 없고 BOM도 없어서 일반 ANSI 인코딩과 아무 차이가 없는 파일의 경우 기본적으로 UTF-8이 아니라 ANSI 인코딩으로 간주하며, 디폴트 인코딩 자체를 UTF-8로 맞추는 기능은 기본적으로 제공되는 않는다는 점이 아쉽다.

다시 말해 새로 만드는 소스 코드라든가, 처음엔 한글이 없었다가 나중에 한글· 한자가 추가된 파일의 경우 실수로 여전히 cp949 같은 재래식 인코딩으로 파일이 저장될 여지가 있다는 것이다. 일일이 저장 옵션을 바꿔 줘야 된다.

Windows 환경에서 UTF-8 인코딩의 C++ 소스 코드는 (1) 주석을 다국어로 작성해서 온전히 보존 가능하고, (2) 동일 파일을 xcode 같은 타 OS에서도 깨지는 문자 없이 공유 가능하다는 것 정도에나 의미를 둬야 할 것이다.
L"" 문자열이 아니라 printf나 WM_SETTEXTA 같은 곳에 쓰이는 "" 문자열은 소스 코드의 인코딩이 무엇이냐에 따라 값 자체가 달라지기 때문이다.

Windows가 진정한 UTF-8 친화적인 환경이 되려면 시스템 코드 페이지 자체를 65001 UTF-8로 지정할 수 있고 명령 프롬프트에서도 그게 지원돼야 할 것이다. 하지만 UTF-8은 (1) 특정 로케일이나 언어에 속해 있지 않다는 점, 그리고 (2) 기존 multibyte 인코딩들과는 달리 한 글자가 3바이트 이상의 길이를 가질 수 있다는 점으로 인해 Windows는 이를 지원하지 않고 있었다. 그래도 요즘 마소가 워낙 파격적으로 변화하고 있고 Windows 10로 하루가 다르게 달라지고 있으니 이런 금기가 앞으로 깨지지 말라는 법도 없을 것 같다.

3. 디버그 로그의 유니코드화

Windows가 10이 나온 이래로 많이 바뀌긴 했다. 완전히 새로운 기능들만 추가되는 게 아니라, 이미 만들어졌고 앞으로 영원히 바뀌지 않을 것처럼 여겨지던 기능까지도 말이다.

가령, OutputDebugString 함수는 통상적인 다른 API 함수들과는 반대로, W가 내부적으로 A 버전을 호출하고 유니코드를 수십 년째 전혀 지원하지 않고 있었다. 이 때문에 굉장히 불편했는데.. 언제부턴가 Visual C++의 디버그 로그 출력창에 surrogate(확장 평면)까지 포함해 유니코드 문자열이 안 깨지고 온전히 찍혀 나오는 걸 보고 개인적으로 굉장히 놀랐다. 나중에 개선된 거라고 한다.

그리고 Windows의 에디트 컨트롤은 개행 문자를 오로지 \r\n밖에 지원하지 않기 때문에 메모장에서 유닉스(\n) 방식의 파일을 열면 텍스트가 개행 없이 한 줄에 몽땅 몰아서 출력되는 문제가 있었다.
그런데 이것도 Windows 10의 최신 업데이트에서는 개선되어서 \n도 제대로 표시 가능하게 되었다.
수백 KB~수 MB 이상 큰 파일을 여는 게 너무 오래 걸리던 고질적인 문제가 개선된 데 이어, 또 장족의 발전이 이뤄졌다.

Windows가 제공하는 유니코드 관련 API 중에는 주어진 텍스트가 유니코드 인코딩처럼 보이는지 판별하는 IsTextUnicode도 있고, 한자 간체-번체를 전환하는 LCMapString 같은 함수도 있다.
IsTextUnicode의 경우, 1바이트 아스키 알파벳 2개로만 이뤄진 아주 짧은 텍스트를 UTF-16 한자 하나로 오진(?)하는 문제가 있어서 내 기억이 맞다면 한 2000년대 Windows XP 시절에 버그 패치가 행해지기도 했다. 사실, 이런 휴리스틱은 정답이 딱 떨어지는 문제가 아니기도 하다.

저런 식으로 알고리즘이 일부 개정되는 경우가 있긴 했지만, Windows에서 데이터 기반으로 동작하는 유니코드 API들은 대개가 한 1990년대 말, 겨우 Windows NT4와 유니코드 2.0 정도나 있던 시절 이후로 그 데이터와 알고리즘이 업데이트 된 내역이 없다.

그래서 간체/번체를 변환하는 테이블에 등재된 한자는 내가 세어 본 기억에 맞다면 2300개 남짓밖에 되지 않으며, IsTextUnicode도 21세기 이후에 유니코드에 새로 추가된 수많은 글자들까지 고려하지는 않고 동작한다.
이 와중에 그래도 Windows 10에 와서 OutputDebugStringW가 제 구실을 하기 시작하고 메모장의 동작이 바뀌기도 한 것이 놀랍게 느껴진다.

한편, UTF-8은 그래도 첫 바이트로 등장할 만한 글자와 그 이후 바이트로 등장할 만한 글자가 형태적으로 무조건 정해져 있다. 그래서 데이터니 통계니 휴리스틱 없이도 자기가 UTF-8이라는 게 딱 티가 나며, UTF-8을 타 인코딩으로 오인한다거나 타 인코딩을 UTF-8로 오인할 가능성이 거의 없는 것이 매우 큰 장점이다.

4. 문자형의 부호 문제

컴퓨터에서 문자열의 각 문자를 구성하는 단위 타입으로는 전통적으로 char가 쓰여 왔다. 그러다가 유니코드가 등장하면서 이보다 공간이 더 커진 wchar_t가 도입되었으며, 언어 표준까지 채택됐다. 값을 다룰 때는 같은 크기의 정수와 다를 게 없지만, 포인터로는 서로 곧장 호환되지 않게 type-safety도 강화되었다.

그런데, 처음에 1바이트짜리 문자열의 기본 타입을 왜 괜히 부호 있는 정수형인 char로 잡았을까 하는 게 아쉬움으로 남는다. 물론 매번 unsigned를 붙이거나 typedef를 하는 건 귀찮은 일이며, 영미권에서는 문자 집합 크기가 7비트만으로도 충분했다는 그 상황은 이해한다. 하지만 문자 코드를 저장할 때는 애초에 부호 따위는 전혀 필요하지 않다. char보다 더 큰 wchar_t도 결코 부호 있는 정수형과 대응하지 않는다.

크기가 겨우 8비트밖에 안 되는 '바이트'는 양수 음수를 따지기에는 너무 작은 타입이기도 하다. 파일이나 메모리 데이터를 바이트 단위로 읽으면서 2의 보수 기반의 부호를 따질 일이 과연 있던가..?

이런 구조로 인해.. UTF-8이건 -16이건 -32이건 모두 대응 가능한 문자열 템플릿을 만들 때, char형에 대해서만 코드값 범위 검사를 할 때 예외를 둬야 하는 불편한 상황도 생긴다. 템플릿 인자로 주어진 어떤 타입에 대해서, 크기는 동일하면서 부호만 없는 타입을 자동으로 되돌리는 방법이 C++ 언어에는 존재하지 않기 때문이다.

그러니 다른 타입에서는 다 간단하게 >= 0x80을 검사하면 되는데, char만 <0을 봐야 한다. 이런 거 로직이 꼬이면 모든 타입에서 0xF0이 저장되어야 하는데 딴 타입에서는 0xFFF0이 저장되는 식의 문제도 생길 수 있다. 그렇다고 임의로 제일 큰 부호 없는 정수형으로 typecast를 하는 건 무식한 짓이다.

회사에서 일을 하다가 하루는 이게 너무 짜증 나서 std::basic_string<unsigned char>로부터 상속받은 클래스를 새로 만들어 버렸다. 베이스 클래스의 명칭이 딱 한 토큰 한 단어가 아니라 저렇게 템플릿 인자가 덕지덕지 붙은 형태인 게 특이했다만.. 생성자 함수에서 기반 클래스를 호출할 때는 __super를 쓰지도 못하더라.

내부적으로는 모든 처리를 unsigned char를 기준으로 하는데, 생성자와 덧셈 연산, 형변환 연산에서만 부호 있는 const char*를 추가로 지원하는 놈을 구현하는 게 목적이었다. 이런 생각을 나만 한 건 절대 아닐 텐데..
개인적으로 + 연산자를 만드는 부분에서 좀 헤맸었다. 얘는 +=와 달리 완전한 내 객체를 새로 만들어서 되돌리는 것이기 때문에 컴파일러 에러를 피해서 제대로 구현하는 게 생각보다 nasty했다. 그렇다고 도저히 못 할 정도는 아니었고.. 뭐 그랬다.

그러고 보니 Java는 기본적으로 부호 있는 정수형만 제공하지만, char만은 문자 저장용으로 부호 없는 16비트 정수를 쓰는 걸로 본인은 알고 있다.
그런데 얘도 그 크기로는 BMP 영역 밖은 표현할 수 없다. Java 언어가 처음으로 설계되고 만들어지던 때는 1990년대 중반으로, 아직 유니코드 2.0과 확장 평면 같은 개념이 도입되기 전이었다.
결국 범용적인 글자 하나를 나타내려면 부호 있는 정수인 int를 써야 한다. 상황이 좀 복잡하다..;;

5. Windows 문자열 변환 함수의 함정

Windows API 중에서 WideCharToMultiByte와 MultiByteToWideChar는 운영체제가 내부적으로 사용하는 2바이트 단위 UTF-16 방식의 문자열과 타 인코딩 문자열(UTF-8, CP949 등..)을 서로 변환하는 고전적인 함수이다.

이 두 함수는 크게 두 가지 모드로 동작한다. (1) 사용자가 넘겨준 문자열 버퍼 포인터에다가 변환을 수행하거나.. (2) 아니면 그렇게 write 동작을 하지는 않고, 이 원본 문자열을 몽땅 변환하는 데 필요한 버퍼의 크기만을 되돌린다.
일단 (1)처럼 동작하기 시작했는데 사용자가 넘겨준 버퍼 크기가 원본 문자열을 모두 변환해 넣기에 충분하지 못하다면 함수의 실행은 실패하고 0이 돌아온다.

이때도 기존 버퍼의 크기만치 변환을 하다가 만 결과는 확인할 수 있다. 하지만 원본 문자열의 어느 지점까지 변환하다가 끊겼는지를 이 함수가 알려 주지는 않는다. 그렇기 때문에 그 정보를 유의미하게 활용하기 어려우며, 이는 개인적으로 아쉽게 생각하는 점이다.

그러니 버퍼가 얼마나 필요한지 알 수 없는 일반적인 경우라면, WideCharToMultiByte를 기준으로 함수를 사용하는 방식은 이런 형태가 된다. 함수를 두 번 호출하게 된다.

const wchar *pSrcBuf = L"....";
int len = WideCharToMultiByte(CP_***, pSrcBuf, -1, NULL, 0, ...); //(2) 크기 측정
char *pTgtBuf = new char[len];
WideCharToMultiByte(CP_***, pSrcBuf, -1, pTgtBuf, len, ...); //(1) 실제로 변환
...
delete []pTgtBuf;

그런데, 이들 함수가 (1)과 (2) 중 어느 모드로 동작할지 결정하는 기준은 버퍼 포인터(pTgtBuf)가 아니라, 버퍼의 크기(len)이다.
보통은 크기를 측정할 때 포인터도 NULL로 주고 크기도 0으로 주니, 함수가 내부적으로 둘 중에 뭘 기준으로 동작하든 크게 문제될 건 없다.

하지만 한 버퍼에다가 여러 문자열을 변환한 결과를 취합한다거나 해서 포인터는 NULL이 아닌데 남은 크기가 우연히도 딱 0이 될 수 있는 상황이라면 주의해야 한다. 포인터와 버퍼 크기가 pTgtBuf + pos, ARRAYSIZE(pTgtBuf) - pos 이런 식으로 정해진다거나 할 때 말이다.

저 식에서 pos가 우연히도 배열의 끝에 도달했다면, 남은 크기가 0이니까 프로그래머가 의도하는 건 이 함수의 실행이 무조건 실패하고 0이 돌아오는 것이다. 그럼 프로그램은 버퍼 공간이 부족해졌다는 걸 인지하여 지금까지 쌓인 버퍼 내용을 딴 데로 flush한 뒤, 변환을 재시도하면 된다.

하지만 이 함수는 프로그래머가 의도한 것처럼 동작하지 않는다. 문자열 변환을 하지 않지만, 마치 실행에 성공한 것처럼 변환에 필요한 버퍼 크기를 되돌린다. 쉽게 말해 "(1)에 대한 실패"가 아니라 "(2)에 대한 성공"으로 처리된다는 것이다.

이 문제를 피하려면, 버퍼를 관리하는 프로그램은 WideChar...함수가 실패했을 때뿐만 아니라 포인터가 버퍼의 끝에 도달했는지의 여부도 따로 체크해야 하며, 그 경우 버퍼를 flush해 줘야 한다. 변환 함수가 후자까지 같이 체크해 주지 않기 때문이다.
날개셋 편집기가 9.5의 이전 버전에, 바로 이것 때문에 대용량 파일을 저장할 때 낮은 확률로 데이터를 날려먹는 버그가 있었다.

Windows API가 입력값이 0일 때에 대해 일관된 유도리가 없어서 불편한 예가 GDI 함수에도 있다.
주어진 문자열의 픽셀 단위 길이와 높이를 구하는 GetTextExtentPoint32의 경우, 문자열 길이에다 0을 주면 가로는 0이고 세로 크기만 좀 구해 줬으면 좋겠는데.. 그러질 않고 그냥 실행이 실패해 버린다.
높이만 구하고 싶으면, 공백 하나라도 dummy로 전해 준 뒤 리턴값의 cx 부분은 무시하고 cy를 사용해야 한다.

Posted by 사무엘

2018/09/29 08:35 2018/09/29 08:35
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1537

1. 파일 포맷 분석

얼마 전에 본인은 Windows용 MS 한글 IME가 내부적으로 사용하는 한자 사전 파일의 분석을 시도한 적이 있었다.
그 결과 파일의 내부 구획과 구조가 상당수 파악되었다. 해당 프로그램이 한글 독음으로부터 한자 정보를 어떻게 얻어 오고 한자로부터 독음, 부수, 획수 등의 정보를 어떻게 얻는지까지도 모두 알아 냈다.
정체를 알 수 없는 구조체들의 숫자들이 의미하는 것도 규칙성을 파악해서 과반은 해독했다. 데이터가 대놓고 압축이나 암호화가 돼 있지는 않은 덕분이었다.

하지만 제일 중요한 문자열 단어 단위로 저장된 정보들은 끝내 얻지 못했다.
이런 것들을 저장하는 용도로 동일한 포맷의 spell trie/tree 같은 게 여러 개 있는데, 이 중에서 가장 간단하고 작은 형태의 테이블을 얻었으며(노드 몇백 개짜리..) 이걸 풀이해 낸 레퍼런스 결과물까지도 역으로 얻었다.
다시 말해 이 작은 테이블로부터 저 결과물이 어떻게 나오는지 그 방식만 알아낼 수 있으면 나머지 진짜 해독해야 하는 더 방대한 테이블까지 사실상 몽땅 해독이 가능해진다.

여기까지 진행됐는데도 더 나아가는 건 도저히 무리였다. 그 이상부터는 각 노드와 노드가 어떻게 연결돼서 한자어나 한자어 훈이 나오는지.. 탐색을 어디부터 시작해야 할지, 단서를 어디서 얻을 수 있을지 추적이 되질 않았다.
지금까지 투입한 시간이 참 아깝기도 했지만 어쩔 수 없이 여기서 눈물을 머금고 포기하게 됐다. 이게 제일 중요한 정보였는데 말이다.

마소의 한국어 IME야 일본어 IME의 내부 구조의 영향을 받은 게 굉장히 많은데, 저 파일의 자료구조 역시 일본어 IME의 DB 내부 구조를 아는 사람에게는 절대로 낯선 형태가 아닐 것이라고 짐작만 해 본다.
답답한 마음에 MS 한글 IME의 내부를 디버깅까지 해 봤다. 그래 봤자 아무 디버깅 단서가 없는 복잡한 0과 1 기계어 천지 속에서 의미 있는 결과가 나오지는 못했고, 단지 쟤들이 DB 파일 전체를 memory mapped file로 걸어서 사용한다는 결론만을 얻을 수 있었다.

아, 내가 갑자기 이거 추적을 한 이유, 동기, 계기는 뭐냐 하면..
날개셋 한글 입력기에서 '조합 안에 조합 생성' 입력 도구를 사용해 봤는데, MS IME의 API를 사용하는 한글-한자 단어 변환 기능이 유난히 동작이 느리고 랙이 심한 걸 발견했기 때문이다. 실제로 테스트를 해 보니 MS IME API는 주어진 한글 단어로부터 한자어를 얻는 게 수십 ms 이상이 걸릴 정도로 느린 편이었다.

그래서 DB 파일 포맷을 알면 내가 저 파일로부터 한자어 리스트를 직접 더 빨리 얻을 수도 있지 않을까 하는 무모한 도전을 해 봤는데.. 뭐 소기의 목적을 다 이루지는 못했다. 파일의 전체 구조가 어떨지 궁금하다.
다만, 내 추측에 따르면 내부의 파일 구조가 검색에 그렇게 효율적인 형태는 아닌 것 같다. 짐작건대 수백~수천 회 이상의 선형 검색이 발생하기라도 하지 않나 싶다.

뭔가, 남이 짜 놓은 C/C++ 코드를 읽는 것뿐만 아니라 이렇게 복잡한 바이너리 파일 구조를 읽으면서 오프셋을 따라가고 추적하는 것..
메모리와 레지스터, 스택 상태를 살피면서 남이 짜 놓은 프로그램을 디버거로 분석하는 것은 프로그래머 내지 소프트웨어 엔지니어로서 필요한 또 다른 스킬인 것 같다.

2. 매크로/스크립트 기능

어느 정도 규모가 있는 업무용 프로그램에는 일괄 처리와 자동화를 위한 매크로 기능이 있다. 도스 시절에는 key 매크로가 많이 쓰였지만, 그건 요즘 같은 GUI 운영체제의 사정에는 잘 맞지 않기 때문에 스크립트 기반으로 가는 추세이다.
어떤 형태로든 이런 기능은 어느 프로그램에서나 어렵지만 강력한 고급 기능으로 취급받곤 한다.

헥사(바이너리) 에디터에 이런 프로그래밍 기능이 있어서 열어 놓은 파일 전체를 거대한 바이트 배열로 접근 가능하고 현재 cursor 위치가 인자로 주어진다면..
여기서부터 분석을 시작했을 때 구조체에 채워지는 값, 여기서 몇 오프셋에 있는 값만치 이동한 곳에 있는 다른 구조체의 값 등등... 을 출력하는 스크립트를 작성할 수 있고 이걸 이용하면 아까 같은 바이너리 파일 읽기나 역공학, 디버깅 같은 작업을 아주 수월하게 할 수 있을 것이다.

그래픽 에디터도 마찬가지다. 2차원 그래픽 툴은 디자이너가 보는 관점과 프로그래머가 보는 관점이 약간 차이가 있다.
프로그래머는 새로운 그림을 그린다기보다는 화면 캡처나 기존 그림을 보정하는 용도로 이런 프로그램을 사용한다.

그렇기 때문에 좌표 같은 걸 매번 마우스로 힘들게 지정하는 게 아니라.. '정확하게 이 구간의 화면 캡처를 몇 번 하라, 색깔이 이런 조건을 만족하는 구간의 테두리를 짤라내라' 이런 명령은 프로그래밍 가능한 체계가 있으면 매우 도움이 된다.
뭐, 거대한 2차원 캔바스에 공식만으로 기하학적인 그림을 그리는 프로그래밍은 덤이고 말이다.

또한 요즘 그래픽 데이터에는 픽셀이 RGB로만 구성된 게 아니라 알파 채널이란 게 있다. 이건 RGB처럼 색깔을 유일하게 구성하는 요소가 아니라 색깔에 추가적으로 붙는 정보이다.
이건 색깔 자체가 아니다 보니 편집하는 방식이 에디터마다 통일돼 있거나 일관성이 있지가 않다. 색깔은 전혀 안 건드리고 알파만 50%로 할지, 아니면 색깔도 건드리면서 알파도 건드릴지, 기존 알파값에다 상대적으로 알파를 더할지 같은 것 말이다.

이런 식으로 일정 구역이나 조건을 만족하는 픽셀에 대해 알파값을 일관되게 고치고 싶을 때 프로그래밍 기반 매크로가 있으면 굉장히 유용하다.
포토샵 같은 그래픽 에디터를 띄웠는데 화면 하단에 마치 옛날 QuickBasic의 immediate 윈도우처럼 그림을 명령어로 조작하는 입력란이 있다면.. 뭔가 그래픽 에디터답지 않은 공대감성이 느껴질 것 같다. =_=;;

3. 개체에 대한 드래그 좌표의 자동 보정

2차원 공간에서 사각형 형태의 여러 개체들을 만들고 배치하는 기능이 있는 프로그램을 생각해 보자. 벡터 드로잉 기능이 있는 파워포인트나 워드 프로세서 같은 프로그램들이 떠오를 것이다.
그리고 일반인이 쓸 일은 잘 없겠지만 개발툴에 내장돼 있는 대화상자/폼 디자이너도 좋은 예이다.

요즘은 그런 프로그램들이 워낙 똑똑해진지라, 개체를 하나 클릭해서 몸통을 마우스로 끌어 보면 개체가 그냥 곧이곧대로만 움직이지 않는다. 옆이나 위의 주변 컨트롤과 나란히, 가지런하게 배치되게 반경 n픽셀 이내의 대충 비슷한 위치를 방황하고 있으면 프로그램이 알아서 '요기?'라고 가이드라인을 제시해 준다.

그 상태에서 마우스 왼쪽 버튼에서 손을 떼면 프로그램이 제시한 정확한 위치에 개체가 달라붙는다. Shift나 Alt 같은 modifier key를 누른 상태로 마우스를 끌어야 그런 보정 위치가 아니라 곧이곧대로 마우스 포인터가 있는 위치에 개체가 자리잡게 된다. Ctrl은 드래그 하는 개체에 대해서 이동/복사 모드를 전환하는 key이니까..

Visual Basic/C# .NET이 제공하는 폼 편집기는 Visual C++이 제공하는 구닥다리 rc 파일 기반의 재래식 대화상자 편집기보다 훨씬 더 똑똑하기 때문에 주변 컨트롤의 위치들을 토대로 온갖 방식으로 자동 달라붙기를 시도해 준다.
xcode도 만만찮았던 걸로 기억한다.

개인적으로는 이런 부류의 기능이 비트맵 그래픽 에디터에도 있으면 좋겠다는 생각을 한다.
그림의 일부 영역을 select할 때.. 색깔이 급격하게 변하는 경계 근처에 가 있으면 정확하게 보정된 위치에 착 달라붙게 프로그램이 보정 위치를 자동으로 제시하는 것 말이다. 그래서 사용자가 불편하게 이미지를 확대해서 조심스럽게 다루지 않아도 되게 말이다.

물론 방대한 크기의 비트맵으로부터 그런 경계 패턴을 인식하는 것은 많아야 수십 개의 개체 좌표값만 계산하면 되는 벡터 드로잉이나 대화상자 폼 편집기보다 힘든 일일 것이다. 그리고 그래픽 에디터라는 게 프로그래머보다는 디자이너가 더 자주 다루는 물건이고, 디자이너는 컴퓨터스럽고(= 도트 노가다 지향적이며) 경계 구분이 분명하고 기하학적인 이미지보다는... 실물 사진을 더 많이 다룰 테니, 수지가 안 맞아서 저런 기능이 들어가지는 않을 것이다. =_=;;

4. 디버깅과 범죄 수사의 유사점

혼자 오랫동안 해 온 생각인데..
강력 범죄 수사랑 컴퓨터 프로그램 디버깅은 절차와 성격 면에서 서로 좀 비슷한 구석이 있어 보인다.
사건이 발생했다는 112 신고는 프로그램에서 무슨 문제가 발생했다는 버그 신고와 같다.
핏자국, 시신, 어지럽혀진 사건 현장 같은 건 프로그램이 뻗은 모습 내지 각종 디버그 로그와 메모리 덤프에 대응한다.

경찰이 사건을 재구성하고 원인을 추적하는 건 두 말할 나위 없이 프로그래머가 동일 상황을 재현해서 버그를 발견하려고 애쓰는 것과 같다.
버그를 잡는 건 당연히 범인 검거이다. 반대로 미제 상태로 공소시효가 끝나는 건 버그를 못 잡은 채로 해당 프로그램의 지원 내지 버전업이 끝나는 것과 같다.

예전에 본인은 법조인이 컴퓨터 프로그래밍과 비슷한 구석이 있다고 글을 쓴 적이 있는데..
형사가 범죄 현상에서 이상한 것 하나라도 놓치지 않고 실마리를 발견하여 사건을 해결하는 능력은 프로그래머의 디버깅 능력과 일맥상통하는 구석이 있어 보인다. 음, 그러고 보니 그걸 한 단어로 추리력이라고 부르는구나.

5. 롤러코스터

2008년 3월, 에버랜드에는 'T 익스프레스'라고 세계구급의 목재 롤러코스터가 개장했다.
그리고 그로부터 10년 뒤인 지난 2018년 5월, 경주월드에서는 낡은 기존 롤러코스터이던 '스페이스 2000'을 철거하고 '드라켄'이라는 더 높고 짜릿한 신기록급 롤러코스터를 개장했다.

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

선박이나 건물뿐만 아니라 롤러코스터를 봐도 목재와 철재의 차이를 알 수 있어 보인다.
목재인 T 익스프레스는 무슨 비행기도 아닌 것이 운행하는 데 날씨 제약을 많이 받으며 혹한기 동계 휴무도 있으며.. 또 결정적으로 빽빽한 지지대들을 설치하느라 층 위에 층을 놓을 수 없다. 롤러코스터 하면 생각나는 배배 꼬인 나선형 선로조차도 만들 수 없다.

항공 사진에서 복층으로 입체 교차하는 듯이 보이는 일부 구간은 거기에만 부득이하게 금속 기둥과 지지대가 예외적으로 조금 설치돼 있다.
그에 반해 드라켄은 얼마나 뻥 뚫린 황량한 형태인지..?? =_=;; 저런 걸 목재로는 구현할 수 없다.

층 위에 층을 만들 수 없는 게 프로그래머의 눈으로 보기엔 무슨 과거 Doom 엔진 기반의 맵 같다. 물론 Doom 엔진으로는 경사진 바닥도 표현할 수 없긴 하다만.. (오로지 평평한 바닥만)
3D 맵으로 뫼비우스의 띠나 클라인 항아리 같은 걸 편법으로라도 구현할 수 있으려나 모르겠다.. ^^

Posted by 사무엘

2018/09/11 08:34 2018/09/11 08:34
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1531

1. R-value 임시 객체를 일반 참조자 형태로 함수 인자로 전달하기

C/C++ 프로그래밍 요소 중에는 잘 알다시피 & 연산자를 이용해서 주소를 추출할 수 있으며 값 대입이 가능한 L-value라는 게 있고, 그렇지 않고 값 자체만을 나타내는 R-value라는 게 있다.
C언어 시절에는 R-value라는 게 함수의 리턴값이 아니면 프로그램 소스 코드 차원에서 리터럴 형태로 하드코딩되는 숫자· 문자열밖에 없었다. 하지만 C++로 와서는 생성자와 소멸자가 호출되는 클래스에 대한 임시 인스턴스 개체도 그런 범주에 속할 수 있게 되었다.

다른 변수에 딱히 대입되지 않은 함수의 리턴값 내지, 변수로 선언되지 않고 함수의 인자에다가 곧장 Class_name(constructor_arg) 이런 형태로 명시해 준 객체 선언은 모두 R-value이다.

R-value를 그 타입에 대한 포인터형으로 함수 인자로 전달하는 것이야 가능하지 않다. 주소를 얻을 수 없으니 말이다. C/C++에 &100 이런 문법 같은 건 존재하지 않는다.
하지만 참조자는 외형상 값으로 전달하는 것과 동일하기 때문에 저런 제약이 없다. 그런데 참조자가 내부적으로 하는 일은 포인터와 완전히 동일하다. 그럼 뭔가 모순/딜레마가 발생하지 않을까?

이런 오류를 막기 위해, R-value를 일반 참조자형으로 전달하는 것은 원래 금지되어 있다. 참조자 내지 포인터는 자신이 가리키는 대상이 당연히 대입 가능한 L-value라는 것을 전제로 깔기 때문이다.
임시 개체를 함수 인자로 전하려면..

  • 그냥 값으로 전달해야 한다. 이 방법은 객체의 크기가 크거나 복사 생성자에서 하는 일이 많다면, 성능 오버헤드가 우려된다.
  • 아니면 const 참조자형으로 전달해야 한다. R-value는 대입이고 변경이고 불가능한 놈이니 const 제약을 줘서 취급하는 것이 이치에 맞다.
  • 아니면 얘는 임시 R-value이지만 값이 보존되지 않아도 아무 상관 없다는 표식이 붙은, C++11의 R-value 참조자 &&를 써서 전달하면 된다.

사실, 정수 같은 아주 작고 간단한 타입이라면 모를까, 커다란 객체는 임시 객체라 해도 어차피 자신만의 주소를 갖고 있는 것이나 다름없다.
그래서 그런지 Visual C++은 200x 언제부턴가 R-value를 통째로 일반 참조자로 전달하는 것을 허용하기 시작했다. xcode에서는 에러가 나지만 Visual C++은 컴파일 된다. 이렇게 해 주는 게 특히 템플릿을 많이 쓸 때 더 편하긴 하다. VC++도 내 기억으로 6.0 시절에는 안 이랬다.

사실, 이걸 허용하나 안 하나.. &로 전달하나 &&로 전달하나 컴파일러 입장에서는 크게 달라지는 게 없다. 템플릿 인자로 들어간 타입이 객체가 아니라 정수라면 좀 문제가 되겠지만 어차피 C++은 서로 다른 템플릿 인자에 대해서 같은 템플릿을 매번 새로 컴파일 하면서 코드 생성과 최적화를 매번 새로 하는 불편한(?) 언어이다. 그러니 각 상황별로 따로 처리해 주면 된다.
물론 R-value 참조자가 C++에 좀 일찍 도입됐다면 Visual C++이 저런 편법을 구현하지 않아도 됐을 것 같아 보인다.

2. C++에서 함수 선언에다 리턴 타입 생략하기

엄청 옛날 유물 얘기이긴 하지만.. Visual C++이 2003 버전까지는 아래 코드가 컴파일이 됐었다는 걸 우연한 계기로 뒤늦게 알게 됐다. 바로 함수를 선언· 정의할 때 리턴 타입을 생략하는 것 말이다.

class foo {
public:
    bar(int x); //생성자나 소멸자가 아닌데 클래스 멤버 함수의 리턴 타입을 생략!! 여기서는 경고는 뜸
};

foo::bar(int x) //여기서도 생략했지만 그래도 int로 자동 간주됨
{
    return x*x;
}

main() { return 0; }

C++은 본격적인 클래스 다루는 거 말고 일상적인 코딩 분야에서 C와 달라지는 차이점이 몇 가지 있다. 그 차이점은 대부분 C보다 type-safety가 더 강화되고 엄격해진다는 것이다.

  • 임의의 type의 포인터에다가 void*를 대입하려면 형변환 연산자 필수. (경고이던 것이 에러로)
  • 함수도 prototype을 반드시 미리 선언해 놓고 사용해야 함. (경고이던 것이 에러로)
  • 그리고 함수의 선언이나 정의에서 리턴 타입을 생략할 수 없음. 한편, 인자 목록에서 ()은 그냥 임의의 함수가 아니라 인자가 아무것도 없는 함수, 즉 void 단독과 동치로 딱 정립됨.
  • 그 대신 C++은 지역 변수(+객체)를 굳이 {} 블록의 앞부분이 아니라 실행문 뒤에 아무 데서나 선언해도 된다.

이 개념이 어릴 적부터 머리에 완전히 박혀 있었는데, VC6도 아니고 2003의 컴파일러에는 C의 잔재가 아직 저렇게 남아 있었구나. 몰랐다.
cpp 소스에다가도 int main()대신 main()이라고 함수를 만들어도 되고, 심지어 클래스의 멤버 함수에다가도 리턴 타입을 생략할 수 있었다니... 완전 적응 안 된다.

물론 익명 함수(일명 람다)를 선언할 때는 꼭 리턴값 타입을 안 써 줘도 된다. void나 int 같은 간단한 것은 함수 내부의 return문을 통해서 컴파일러가 그럭저럭 유추해 주기 때문이다.
함수형이라는 완전히 다르고 새로운 패러다임이 C++에 한참 뒤에 추가되다 보니 일관성이 깨졌다면 깨진 듯이 보인다.

3. C/C++ 컴파일러 및 언어 문법 자체의 변화

내 경험상, 지난 2000년대에는 Visual C++ 컴파일러의 버전이 올라가면서 명칭의 scope 인식이 좀 더 유연해지곤 했다.
가령, 클래스 A의 내부에 선언된 구조체 B를 인자로 받는 멤버 함수 C의 경우, C의 몸체를 외부에다 정의할 때 프로토타입 부분에서 B를 꼭 A::B라고 써 줘야 됐지만, VC6 이후 버전부터는 그냥 B라고만 써도 되게 됐다.

람다가 최초로 지원되기 시작한 미래의 VC++ 2010에도 비슷한 한계가 있었다.
클래스 멤버 함수 내부에서 선언된 람다 안에서는 그 클래스가 자체적으로 정의해 놓은 타입이나 enum값에 곧장 접근이 안 됐다. A::value 이런 식으로 써 줘야 했는데.. 2012와 그 후대 버전부터는 곧바로 value라고만 써도 되게 바뀌었다. 2012부터 람다가 함수 포인터로 cast도 가능해졌고 말이다.

내 기억이 맞다면 템플릿 인자가 공급된 템플릿 함수가 함수 포인터로 곧장 연결되는 게 VC++ 2003쯤부터 가능해졌다. 상식적으로 당연히 가능해야 할 것 같지만 VC6 시절에는 가능하지 않았었다.
2000년대가 generic이라면 2010년대는 functional 프로그래밍이 도입된 셈이다.

아.. 또 무슨 얘기를 더 할 수 있을까?
C는 옛날에, 원래 처음에 1980년대까지는 K&R 문법인지 뭔지 해서 함수의 인자들의 타입을 다음과 같이 지정할 수도 있었다고 한다.

int foo(a, b) int a; float b; { return 0; }

같은 명칭을 이중으로 명시하고 체크하느라 괜히 분량 길어지고 파싱이 힘들어지고..
무슨 Ada나 Objective C처럼 함수를 호출할 때 foo(b=20, a=1)처럼 인자들 자체의 명칭을 지정하는 것도 아닌데..
저 문법이 도대체 무슨 의미가 있고 왜 저런 게 존재했는지 나로서는 좀 이해가 안 된다. C는 파이썬 같은 dynamic 타입 언어도 전혀 아닌데 말이다.

C 말고 C++도 1980년대까지는 문법이나 라이브러리가 제대로 표준화되지도 않았었고, 지금으로서는 상상하기 어려운 관행이 많았다고 한다. 유명한 예로는 소멸자가 존재하는 클래스 객체들의 배열을 delete 연산자로 제거할 때는 원소 개수를 수동으로 공급해 줘야 했다거나, static 멤버를 선언한 뒤에 굳이 몸체를 또 정의할 필요가 없었다거나 하는 것 말이다.

사실, 후자의 경우 또 정의할 필요가 없는 게 더 직관적이고 프로그래머의 입장에서 편하긴 하지만.. 컴파일러와 링커의 입장에서는 생성자 함수를 호출하는 실행문이 추가되는 부분이 따로 들어가는 게 직관적이니 불가피하게 생긴 변화이긴 하다.
이런 저런 변화가 많은데 C++은 표준화 이전의 격변의 초창기 시절에 대한 자료를 구하기가 어려워 보인다.

객체를 생성하는데 메모리 주소는 정해져 있고 거기에다 생성자 함수만 호출해 주고 싶을 때..
지금이야 placement new라는 연산자가 라이브러리의 일부 겸 사실상 언어의 일부 요소로 정착해서 new(ptr) C라고 쓰면 되지만, 옛날에는 ptr->C::C() 이런 표기가 유행이었다. . ->를 이용해서 생성자와 소멸자 함수를 직접 호출하는 건 지금도 금지되어 있지는 않지만.. 사용이 권장되는 형태는 아닌 듯하다.

4. macOS와 Windows의 관점 차이

macOS의 GUI API(Cocoa)와 Windows의 GUI API는 뭐 뿌리나 설계 철학에서 같은 구석을 찾을 수 없을 정도로 완전히 다르다.
좌표를 지정하는데 화면이 수학 좌표계처럼 y축이 값이 커질수록 위로 올라가며, NSRect는 Windows의 RECT와 달리 한쪽이 절대좌표가 아니라 상대좌표이다. 즉, 두 개의 POINT로 구성된 게 아니라 POINT와 SIZE로 구성된 셈이다.

또한 Windows의 사고방식으로는 도저히 상상할 수 없는 float 부동소수점을 남발하며, 처음부터 픽셀이라는 개념은 잊고 추상적인 좌표계를 쓴다. 얘들은 이 정도로 장치· 해상도 독립적으로 시스템을 설계했으니 high DPI 지원도 Windows보다 더 유연하게 할 수 있었겠다는 생각이 들었다.

그리고 대화상자나 메뉴 같은 리소스를 관리하는 방식도 둘은 서로 다르다.
Windows는 철저하게 volatile하다. 이런 것은 생성되는 시점에서 매번 리소스로부터 처음부터 load와 copy, 초기화 작업이 반복되며, 사용자가 해당 창을 닫은 순간 메모리에서 완전히 사라진다.

그 반면, 맥은 그런 것들이 사용자가 닫은 뒤에도 이전 상태가 계속 남아 있다.
내가 뭔가 설정을 잘못 건드렸는지는 모르겠지만, Windows로 치면 [X]에 해당하는 닫기 버튼을 눌러서 대화상자를 닫았는데도 호락호락 창이 사라지지 않고 DestroyWindow보다는 ShowWindow(SW_HIDE)가 된 듯한 느낌? 대화상자의 이전 상태가 계속 살아 있는 것 같다.

특히 메뉴의 경우 Windows는 내부 상태가 완전히 일회용이다. 메뉴가 화면에 짠 표시될 때가 되면(우클릭, Alt+단축키 등) 매번 리소스로부터 데이터가 로딩된 뒤, 체크 또는 흐리게 같은 다이나믹한 속성은 그때 그때 새로 부여된다.
그 반면 mac에서는 메뉴가 화면에 표시되지 않을 때에도 내부 상태가 쭉 관리되고 있는 게 무척 신기했다.

그러니 개발자는 시간과 여유만 된다면 프로그래밍 언어와 환경을 많이 알면 사고의 범위가 넓어질 수 있고 각각의 환경을 설계한 사람의 관점과 심정을 이해할 수도 있다.
하지만 뒤집어 말하면 프로그래머는 은퇴하는 마지막 순간까지도 공부하고 뒤따라가야 할 게 너무 많다.. -_-;; 이거 하나 적응했다 싶으면 그새 또 다른 새로운 게 튀어나와서 그거 스펙을 또 공부해야 된다.

Posted by 사무엘

2018/09/08 08:37 2018/09/08 08:37
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1530

1. 실행 파일의 자가 업데이트

내가 개인적으로 만들어서 배포하는 날개셋 프로그램들에는 서버 트래픽, IME의 갱신과 관련된 Windows 특유의 기술적 난항, 개발자의 귀차니즘(..) 등의 이유로 인해 자동 업데이트 기능이 없다. 하지만 회사에서 상업용으로 개발하는 프로그램에다가는 프로그램의 자가갱신 기능을 구현할 일이 좀 있었다. 이를 위해서 Windows에서 자기 자신을 삭제하는 실행 파일을 만들 때와 비슷한 방식의 시나리오를 설정했다.

  1. 업데이트 대상인 app.exe는 새 버전인 app_new.exe를 다운로드하여 임시 디렉터리에다 보관한다.
  2. app.exe는 별도의 updater를 실행하고 자기 프로세스 ID와 임시 파일 이름을 명령 인자로 전한다. 그 뒤 자신은 신속히 종료한다.
  3. updater는 app.exe가 종료할 때까지 기다린 뒤, 실제로 종료되면 app.exe를 삭제한다. 그리고 임시 파일을 app.exe로 옮기고 개명한다.
  4. 그 뒤 updater는 새로 받은 app.exe를 실행한다.

그렇게 본 프로그램과 업데이터까지 잘 만들었다. 업데이터는 Program Files 아래의 디렉터리에서 파일을 지우고 생성할 것이기 때문에 관리자 권한으로만 실행되게 매니페스트 설정도 물론 해 놨다.

그런데 이렇게 만들어진 프로그램은 개발 환경을 포함해 여러 PC에서 업데이트 기능이 동작했지만, 일부 PC나 가상 머신에서는 제대로 동작하지 않았다.
관리자 권한을 분명히 줬는데 도대체 어디서 문제가 발생하는지, 그리고 같은 Windows 10에서 컴퓨터마다 성공 여부에 왜 차이가 발생하는지 기괴하기 그지없었다. 디버깅 결과 다음과 같은 복병을 발견하게 되었다.

(1) GetModuleFileNameEx는 만능이 아니다. 경로 정보를 얻고자 하는 모듈이 존재하는 프로세스의 비트수(32 또는 64비트)와.. 정보를 요청하는 나 자신의 비트수가 일치해야만 경로를 얻을 수 있더라.
난 이 함수가 그런 이유로 인해 실패할 수 있다고는 꿈에도 생각하지 않고 있었다. 문서에 딱히 언급이 없었으니까... 비트수가 안 맞으면 ERROR_PARTIAL_COPY라는 굉장히 낯설고 기괴한 에러 코드와 함께 함수의 실행이 실패했다. (Only part of a ReadProcessMemory or WriteProcessMemory request was completed.)

(2) 그리고 더 뒷목 잡는 상황은 따로 있었다.
업데이터는 app이 종료될 때까지 기다렸다가 파일을 대체하도록 만들어졌다. 그런데 컴퓨터에 따라서는 그 반대편으로 극단적인 상황도 발생했다. 바로, app이 너무 일찍 종료되거나 업데이터가 너무 늦게 실행되는 바람에 정작 OpenProcess 요청부터가 실패하여 app에 대한 정보를 전혀 얻지 못한 것이다.

그러니 이때는 app과 업데이터가 서로 공유하는 이벤트 같은 오브젝트라도 만들어서 app과 업데이터가 공존하는 타이밍이 반드시 존재하게 할 수도 있고, 아니면 app이 자신의 족적을 공유 메모리나 아예 임시 파일 같은 다른 방법으로 넘겨서 업데이터에다가 전달하게 문제를 회피해야 했다. 뭐, 이런 일이 있었다.

요즘은 어지간히 대단하고 전문적인 제품이 아닌 한, 소프트웨어를 받아서 사용하는 것 자체만으로 최종 사용자에게 금전적인 대가를 요구하는 관행은 사실상 없어졌다. 소프트웨어는 그냥 고객을 확보하고 개발사의 인지도를 올려서 더 교묘한 수단으로 수익을 추구하는 통로일 뿐이다.

소프트웨어를 무료로 뿌리는 대신에 개발사는 사용자로부터 그 소프트웨어의 사용 형태를 최대한 미주알고주알 수집하려 하는 게 요즘 추세이다. 단순히 (1) 최신 버전 체크/업데이트라든가 프로그램이 뻗어서 (2) 메모리 덤프 같은 디버깅 정보를 제출하는 용도로 서버와 교신하는 게 아니라, 자주 사용하는 기능 같은 전반적인 행동 데이터를 종종 개발사로 보낸다는 것이다.
물론 이건 사용자에 따라서 불쾌한 사생활 침해로 여겨질 수 있으니 사용자가 동의했을 때에만 보내게 돼 있다. MS Office, Visual Studio, Android Studio 등의 제품에 다 이런 옵션이 존재한다.

2. 경과 시간을 되돌리는 함수

자동 업데이트 기능은 마지막으로 업데이트 체크를 했던 시각을 레지스트리에다 저장해 둔다. 그리고 프로그램이 처음 실행되면 현재 시각이 레지스트리에 기록된 시각으로부터 1주 이상 경과했는지 검사한다. 조건이 만족되면 서버에 등록된 최신 버전을 지금 내 버전과 비교하고.. 이하 생략의 순서대로 구현되었다.

그런데 프로그램을 다 만들고 테스트를 해 보니, 아무리 서버에 더 높은 버전 정보가 등록됐고 그로부터 1주가 넘는 시간이 경과해도 이 프로그램은 자동 업데이트가 행해지지 않았다.
도대체 뭐가 문제인지 코드를 한참을 들여다 본 뒤에야 "아차...!" 하고 탄식을 내뱉었다.

GetTickCount()는 1970년 1월 1일 같은 고정된 epoch으로부터 경과한 시간을 되돌리는 게 아니라, 그냥 시스템이 부팅이 끝난 이래로 경과한 시간을 되돌리는 함수이지 않던가.
그러니 한번 컴퓨터를 켜 놓고 1주일 이상 버티지 않는 이상, 업데이트 체크가 행해질 리가 없었던 것이다.
내가 뭘 잘못 먹고 착각을 했는지, 코딩 하다가 졸았는지, 왜 그 상황에서 저 함수를 쓸 생각을 했는지는 알 길이 없다.

3. message loop

Windows에서 MFC를 안 쓰고 응용 프로그램 GUI를 구현하다 보면 결국 (a. PeekMessage) → GetMessage → (b. TranslateMDISysAccel, TranslateAccelerator) → (c. IsDialogMessage) → TranslateMessage → DispatchMessage 등의 순으로 함수를 호출하는 message loop도 손수 만들게 된다.
괄호를 친 a~c는 필수는 아닌 함수들이다. a는 idle time processing이 필요할 때만 쓰면 되고, b는 MDI 프로그램이라거나 자체 단축키가 있을 때, 그리고 c는 혹시 modeless 대화상자가 존재할 때에만 쓰면 된다.

본인이 개발하던 문제의 프로그램은 타 프로세스로부터 받은 메시지에 반응하여 대화상자를 표시하는 기능이 있었다. 그런데 만들어 놓고 보니 당장은 동작하는 것 같지만 뭔가 미묘하게 정상이 아닌 상태 같았다.
대화상자가 떠 있는 동안에도 우리 쪽에서 정의한 custom 메시지를 처리할 수 있게 하기 위해 번거롭지만 대화상자를 modal 대신 modeless 형태로 바꿨다.

그런데 tab을 눌러서 컨트롤간 포커스를 이동시켜 보면 이상한 비프음이 나면서 이동하고, Alt+Space로 시스템 메뉴를 꺼내서 창을 닫으면 제대로 동작하지 않고 프로그램 동작이 수 초간 멎는다거나..
owner(parent) 윈도우를 타 프로세스의 윈도우로 지정해서 그런가, 처음에는 쓸데없는 부분을 의심하며 한참을 헤맬 수밖에 없었다.

이것도 코드를 한참을 들여다 본 뒤에야 내가 무슨 삽질을 했는지를 깨닫고 경악하게 됐다. message loop을 이런 식으로 짰기 때문이었다.

while(GetMessage(&msg, NULL, 0, 0)>0) {
    for(HWND hDlg: m_hModelessDlgs)
        if(hDlg && ::IsDialogMessage(m_hDlg, &msg)) continue;
    ::TranslateMessage(&msg); ::DispatchMessage(&msg);
}

꺼내 놓을 수 있는 modeless 대화상자가 여러 종류가 추가되면서 나름 저렇게 for문을 넣었는데..
난 저 continue가 여전히 while문 전체를 제낄 수 있다고 생각했던 것이다.
그러니 대화상자로만 가야 할 메시지가 여전히 DispatchMessage로도 이중으로 전해지고 있었고, 이 때문에 뭔가 동작은 하는 것 같은데 잡다한 부작용까지 덩달아 발생하고 있었다.

내가 Windows 프로그래밍 경력은 10년을 훌쩍 넘기지만.. 그래도 정신이 없을 때는 아직까지 이런 초보적인 실수도 한다.

4. 대화상자를 종료하는 법

대화상자를 하나 구현했다.
사용자는 메뉴를 거쳐서 그 대화상자를 열고 닫는데, 그걸 한번 닫은 뒤부터는 메뉴를 선택해도 그 대화상자가 다시 열리지 않는 버그가 있었다. 그것도 언제나 그러는 것도 아니고 특정 기능을 사용해서 간접적인 방법으로 대화상자를 닫았을 때 문제가 발생했다.

디버깅 끝에 밝혀진 원인은 바로.. modeless 대화상자를 EndDialog 함수를 이용해서 닫았기 때문이었다.
modal 대화상자를 훗날 modeless 형태로 개조하면서 발생한 부작용의 연장선이다. 대화상자를 닫는 부분을 그에 맞게 제대로 고치지 않았었다.

EndDialog를 호출했더니 그 대화상자는 ShowWindow(hDlg, SW_HIDE)를 한 것처럼 화면에서 사라지기만 할 뿐, 실제로 파괴되고 없어지지는 않았다. modeless 대화상자는 여느 윈도우를 없앨 때처럼 DestroyWindow 함수를 직접 호출하든가 WM_CLOSE를 날려서 없애야 한다. 오오.. 이것도 의외로 자주 할 수 있는 실수로 보인다.

그럼 반대로 modal 대화상자의 프로시저에서 자기 자신을 DestroyWindow로 날려 버리면 어떤 일이 벌어질지가 궁금해진다. EndDialog와 달리 DestroyWindow는 IDOK나 IDCANCEL 같은 리턴값을 지정할 수 없다. 그렇기 때문에 이때 DialogBox 계열 함수의 리턴값은 그냥 '취소'를 눌러 종료된 것과 동급으로 취급되어야 하지 않나 싶다.

5. 맺는 말

이런 일련의 경험을 통해, "타 프로세스로부터 받은 메시지에 반응하여 대화상자를 표시하기" 이게 생각보다 더욱 기괴한 상황이란 걸 실감할 수 있었다.

프로세스 A가 프로세스 B로 메시지를 send로 보냈는데 B가 대화상자나 메시지 박스 같은 modal UI를 표시하고, 그게 종료된 뒤에야 return을 해 준다면.. 그 동안 프로세스 A는 자기 메시지를 처리하지 못하는 '응답 없음' block 상태가 돼 버린다. 특히 그 상태에서 B가 A로 역으로 메시지를 보내기라도 한다면 둘 다 꼼짝없이 dead lock에 빠진다.

메시지를 주고받는 주체와 객체가 다 동일 프로세스의 동일 스레드 소속이라면 걱정할 것 없다. 그 modal UI를 돌리는 B쪽의 메시지 loop에서 A에 소속된 윈도우에게 WM_PAINT 같은 메시지가 온 것도 같이 처리를 해 주기 때문이다. 하지만 프로세스, 아니 스레드 소속만 달라도 이런 일이 저절로 같이 행해지지 않는다.

그렇기 때문에 프로세스와 스레드 경계를 넘나들며 UI를 표시할 때는 대화상자는 최대한 modeless 형태로 만들고, 메시지도 post가 아니라 반드시 send로 보내야겠다면 ReplyMessage를 호출해서 "선응답 후UI" 형태로 프로그램 UI의 반응성을 보장해야 한다.

사실, 같은 프로세스이더라도 자기와 소속이 다른 스레드에 속한 윈도우로는 SetFocus를 할 수 없으며 심지어 DestroyWindow조차도 직통으로 먹히지 않는다. AttachThreadInput를 써서 입출력 연결을 한 뒤에나 포커스 지정이 가능하며, 윈도우의 파괴는 WM_CLOSE를 보내는 방식으로 간접적으로 해야 한다. WM_CLOSE는 WM_DESTROY와 달리, 자신이 파괴되는 것을 해당 윈도우 프로시저가 거부할 수 있다.

Posted by 사무엘

2018/05/26 08:34 2018/05/26 08:34
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1493

1. HTTP 통신 고수준 API

오늘날 운영체제의 GUI API가 qt 같은 별도의 프레임워크가 나온 걸 제외하면 통일된 게 없고(통일이 될 수가 없음..) 그래픽 API가 통합된 게 없듯이..
네트워크, 특히 HTTP/HTTPS 기반 통신 API 역시 내가 아는 한 뭔가 ANSI/ISO 차원의 표준이 나온 게 없이 운영체제마다 다 파편화돼 있는 것 같다. 물론 제일 저수준의 소켓 API는 그럭저럭 표준화가 돼 있지만 오늘날 그것만 써서 밑바닥부터 프로그램을 짜는 건 클라이언트건 서버건 무리이니 말이다. 그러니 Windows, 안드로이드, iOS/macOS마다 또 API를 새로 익혀야 하는 게 다소 번거롭다.

요즘 인터넷에서 다른 프로토콜들은 거의 듣보잡이 돼 가고 HTTP/HTTPS만 남은 것 같다.
통합 라이브러리는 스위치만 달리해서 동일한 정보를 (1) GET와 POST 두 방식으로 간편하게 보낼 수 있으며, 이때 argument를 적절히 배치해 줘야 한다. 전자는 주소에다가 넣고 후자는 헤더에다가 넣어야 할 것이다.

URL 인코딩 처리를 적절히 해 줘야 하며, 바이너리 데이터 덤프를 base64로 인코딩, 또는 디코딩 하는 라이브러리도 덤으로 제공하면 좋을 것이다. post로 보낼 때는 Content-Type, Content-Length 같은 상투적인 헤더를 당연히 자동으로 넣어 줘야 한다.

Windows에서는 메시지를 보내는 방식이 post의 반대가 send인데, HTTP에서는 요청을 보내는 방식이 post의 반대가 get이다. get은 어떤 정보를 얻어 오기만 하는 read-only operation용으로 쓰고, post는 서버다 데이터를 등록하고 변경하고 삭제하는 등 side effect가 남는 요청을 할 때 쓴다. 일단은 그러하지만 홍보가 부족해서 현실에서는 그 용도가 엄밀하게 지켜지지는 않고 있다.

POST 방식 요청의 연장선이겠다만 (2) 다운로드뿐만 아니라 업로드도 당연히 지원해야 할 것이고, 받는 데이터 내지 올리는 데이터는 (3) 메모리, 파일명, 임의의 스트림 형태로 자유롭게 공급이 가능해야 한다. 전부 다 필요하기 때문이다.

그리고 아주 짤막한 파일을 주고받을 때나 UI 없이 그냥 명령줄 프로그램을 만들 때를 대비해서 그냥 (4) 동기형(응답이 올 때까지 block) + 짧은 대기 시간 형태도 지원하고, 별도의 스레드에서 돌아가는 비동기 방식도 자체 지원해야 한다.
비동기 방식은 결과가 왔을 때 콜백 함수 호출 하나로 끝나는 간편한 것을 생각할 수 있고, 한편으로 수십~수백 MB짜리 파일을 주고 받으면서 전송 상태를 늘 확인할 수 있는 복잡한 형태도 생각할 수 있는데, 둘 다 가능해야 한다.

단순 업데이트 체크 같은 건 굳이 지금 당장 안 돼도 상관 없는 것이니 프로그램의 반응성에 영향을 줘서는 안 될 것이다. 스마트폰 앱 같은 게 비행기 모드이거나 3G 데이터를 사용하고 있을 때는 괜찮은데 외부의 희미한 와이파이에 접속해 있을 때는 굼뜨는 경우가 있다. 이런 일이 없어야 할 것이다.

Java는 네트워크 쪽 클래스를 사용할 때는 try catch로 예외 처리가 무조건 돼 있어야 하고 안 그러면 컴파일조차 되지 않는데, 안드로이드에서는 그것도 모자라서 네트워크 통신은 무조건 별도의 스레드에서만 동작하게.. main GUI 스레드에서는 쓸 수도 없게 만들어 놓았다. 네트워크 에러 때문에 프로그램 전체의 동작· 반응이 멎는 걸 원천봉쇄하기 위한 조치인데, 이것 때문에 아주 간단한 소규모 통신에 대해서까지 일일이 스레드 만들고 함수 분리하는 건 번거롭기도 한 게 사실이다.

통신 API로 할 수 있는 일을 생각해 보니 이 정도인 것 같다. 이 모든 상황에 대한 대처가 가능하고 크로스 플랫폼까지 지원한다면 훌륭한 통신 API가 될 수 있을 것이다.

2. Windows CE 프로그래밍

회사에서는 이것저것 찝적대느라 안드로이드, macOS 코코아에 이어 근래엔 골동품인 Windows CE까지 만질 일이 있었다.
도스 기반의 Windows (1985), NT 커널(1993)에 비해 CE는 임베디드 환경을 타겟으로 나름 제일 나중에(1996) 등장했다. PC용 OS들과는 달리 CE는 리얼타임 OS이다.

그리고 CE는 훗날 Embedded Compact라고 이름이 미묘하게 바뀌었다가 2010년대부터 Windows Mobile, Windows Phone 등 스마트폰 OS로 변모하는가 싶더니.. 그냥 Windows 10 자체가 ARM용으로도 나오면서 모바일 분야를 흡수해 버렸다. 또한, 스마트폰 OS는 마소가 판단하기에도 Windows가 안드로이드와 iOS의 적수는 도저히 안 되겠다고 여겨져서 그냥 접어 버리고.. 그 대신 Visual Studio가 무려 안드로이드 개발도 지원하는 식으로, 완전히 다른 형태로 변화하게 되었다.

아무튼, 이 와중에 지금으로서는 좀 구닥다리가 된 Windows CE를 만져 봤다.
GUI에서 본인에게 아주 익숙한 Windows API로 프로그래밍 가능한 건 좋은 점이었다. 단, 같은 API를 사용하더라도 헤더 파일 구조가 PC와 동일하지는 않았으며, 생성되는 바이너리도 비록 PE 방식의 실행 파일이긴 하지만, PC Windows 버전처럼 kernel32, gdi32, user32 이런 DLL들 import가 있지는 않는 게 흥미로웠다.

그 대신 coredll.dll에 API들이 한데 몰빵돼 있는 듯하다. 그리고 메모리 절약을 위해 함수들은 이름이 아니라 ordinal 번호만으로 import하게 돼 있다.

static 라이브러리 같은 걸 만들어도 PC용과 CE 기기용을 동일 소스 기반으로 유지하기는 좀 어려워 보였다. 글쎄, #include 부분에 조건부 컴파일 로직을 정교하게 잘 짜 놓으면 불가능하지 않을지도 모르겠지만.. 거기까지는 잘 몰라서 말이다.
그러고 보니 옛날엔 Visual C++도 Embedded VC++라고 CE 개발 전용 에디션이 있긴 했다가 그건 2000년대 이후로 본가에 흡수됐다.

Visual Basic 6이라든가 Visual C++ 6은 후대 버전과 단절이 커서 꽤 오랫동안 쓰인 개발툴이라는 공통점이 있다. 그런데 Visual C++ 2008도 비슷한 면모가 있다. 바로 Windows Phone이 등장하기 전의 Windows CE 시절, 더 정확히는 Embedded Compact 6.5와 그 이전 버전 레거시 플랫폼의 개발을 지원하는 마지막 버전이라는 것이다.

자동차 내비에서도 이 운영체제가 돌아가는 경우가 있다.
PC용으로 오랫동안 개발되어 온 C++ 코드를 이 플랫폼과 컴파일러용으로 포팅하려다 보니 지금까지 편하게 써 왔던 auto와 lambda, nullptr 따위를 몽땅 구 문법으로 다시 써 주는 불편을 감수해야 했다. 저기서는 2010년대 이후에 새로 도입된 C++ 문법이 지원되지 않기 때문이다.

이 특정 플랫폼만 그런지는 모르겠지만, 인상적인 점은 뭔가 듬성듬성 빠진 함수가 많다는 것이었다. 덩치를 줄이기 위해서 다른 우회 대체제가 있다 싶은 건 몽땅 빼 버린 모양이다.
일례로, DrawText와 ExtTextOut은 있지만 더 단순한 함수인 TextOut은 없다(더 범용적인 대체제가 있으니까..). 파일 시스템도 도대체 뭐 어찌 되는지 GetCurrentDirectory, GetWindowsDirectory 이런 거 없다.

칼질은 C 함수 쪽도 예외가 아니다. 엄연히 ANSI 표준인 time 함수가 없어서 Windows API를 이용한 대체 함수를 직접 만들어야 했다.
그리고 어찌 된 일인지 FILE* 스트림 기반의 fopen 계열 말고, 정수형 파일 핸들을 주고 받는 저수준 _create, open 계열의 함수도 없더라. PC에서 쓰던 코드를 곧장 포팅하는 건 생각만치 쉽게 되지는 않았다.

제일 압권인 건 CE는 9x와는 정반대로.. API 함수에 W 버전만 있고 A 버전이 깔끔하게 없다는 것이었다.
9x는 W 버전도 껍데기는 있지만 실행이 에러 코드와 함께 몽땅 실패한다. CE에서는 A 버전이 컴파일은 되지만 링크가 되지 않고 곧장 실패했다. 1996년에 첫 개발된 신흥 OS이다 보니, 안 그래도 용량도 부족한데 구닥다리 레거시인 A는 처음부터 배제해 버린 셈이다.

Windows CE에는 W만 있고 A가 없다는 건 먼 옛날에 제프리 릭터 아저씨의 책에서 처음으로 봤는데 실제로 그렇다는 걸 그로부터 10수 년 이상 뒤에야 실제로 확인할 수 있었다.

3. 라이브러리의 형태의 변화

도스 시절에 프로그래밍에 필요한 무슨 라이브러리, API, 또는 SDK라 하면..
어셈블리어로 도스의 각종 특이한 인터럽트를 호출해서 하드웨어를 직통으로 건드리는 마우스 라이브러리, 그래픽/사운드 라이브러리, 심지어 한글 라이브러리 같은 게 많았다. 이런 기능들은 타 언어도 지원했지만 가장 먼저는 C/C++용 헤더와 라이브러리의 형태로 제공되었다.

Windows로 넘어가서는 어지간한 하드웨어 제어는 운영체제의 자체 API만 써도 커버가 되니 3rd party 라이브러리 같은 건 필요가 크게 줄어들었다. 그냥 MS Office와 Visual Studio의 외형을 흉내 내 주는 GUI 툴킷이라든가.. 이미지 파일 처리 라이브러리가 쓰였다. 아 그리고, DirectX SDK는 Windows 플랫폼 SDK로부터 완전히 분리 독립했기 때문에 따로 설치해야 하는 물건이 되긴 했다.

그런데 요즘은 그런 API, 라이브러리라는 게 용도와 형태가 바뀌고 있다. PC 안에서 혼자 돌아가는 프로그램이 고차원적인 하드웨어 제어 기능을 사용하는 게 아니라.. 웹사이트나 스마트폰 앱에서, (1) 특정 대규모 서버에서 제공하는 각종 실시간 정보 제공 기능(날씨, 버스와 지하철 위치, 지리 정보 등등)이라든가 (2) 아주 고수준의 인공지능 내지 패턴 인식 기능(이미지에서 사람 얼굴 인식, 음성 인식 등) 같은 것을.. 1인 개미 개발자가 자기 앱이나 사이트에서 곧장 사용할 수 있게 해 준다.

방대한 인공지능 데이터 검색과 계산은 서버가 담당한다. 바둑 AI로 치면 AI 코드가 라이브러리에 담겨 있는 게 아니라, 별도로 돌아가는 알파고 서버와 통신만 하는 거다.
언어는 무겁고 부담스러운 C/C++과는 거리가 멀며, JavaScript, Go, 파이썬 같은 것을 곧장 지원한다.
과금 체계도 과거의 전통적인 형태와는 사뭇 다르다. 비싼 라이브러리 제품을 몇만~몇십만 원 일시불로 지불하고 사 오는 게 아니라.. 월 얼마씩~ 사용 트래픽이 얼마까지는 무료이고 그 뒤부터는 한 건당 얼마 이런 식으로 매겨진다. 구매 대신 임대 형태이다.

기술은 그 자체는 온통 오픈소스다 뭐다 하면서 무료에 가깝게 공개되고 상향평준화되고 있다. Google API의 기능들을 개발하는 데 컴퓨터공학· 전산학 박사들이 얼마나 많이 투입됐을까..? 그 다음으로는 그냥 자본과 데이터 물량전이 대세이다.
그리고 소프트웨어로 돈 버는 건 기술과 기능 자체가 아니라 그걸로 온통 사용자들의 감성을 사로잡고, 자아· 정체성을 표현해 주는 대가로 형태가 바뀌는 것 같다. 온라인 게임의 부분 유료화라든가 각종 아바타가 대표적인 예이고 말이다.

아, 그렇다고 지금이 전통적인 형태로 판매되는 라이브러리· 미들웨어가 전멸했다는 얘기는 아니다. 게임에서 주로 쓰이는 네트워크 라이브러리, 동영상 캡처 라이브러리 같은 것들은 전적으로 로컬에서 기능이 동작하며, 단품 또는 출시되는 제품 타이틀의 규모 단위로 판매되고 있다. 돈을 한번 지불하고 끝인 것도 있고, 일정 주기로 꼬박꼬박 로얄티를 내는 형태인 것도 있다.

4. 함수 호출과 금융의 관계

좀 엉뚱한 생각을 하자면, 금융을 프로그래밍에다가 비유하고 돈이 오가는 걸 함수 호출과 데이터 전달에다가 비유할 수 있을 것 같다.

현금이 오가는 건 데이터 실물을 통째로 스택에다 얹는 call by value이다. 제일 단순하고 확실하지만 거액의 현금을 매번 들고 다니는 건 번거롭고 귀찮고 위험하다.
그러니 현금, 또는 가까운 미래에 들어올 예정인 돈에 대한 포인터가 등장하는데, 이것들이 바로 수표나 어음이다. call by reference가 현실 세계에도 존재하는 셈이다.
부도는 당연히 잘못된 포인터 접근으로 인한 page fault 내지 access violation과 직통 대응이다.

컴퓨터에는 지금 물리적으로 실제로 있는 메모리보다 더 많은 주소 공간을 끌어다 쓰고, 공간이 없으면 디스크 스와핑이라도 하는 '가상 메모리'라는 개념이 있다. 이게 어찌 보면 '대출'과 비슷한 개념으로 보인다.
물론 컴퓨터의 동작에 경제 용어인 '신용'과 정확히 대응하는 개념이 존재하지는 않는다. 그러니 완전 동일하게 대응하지는 않겠지만.. 그래도 이런 식으로 비유해 보면 생각보다 그럴싸하고 씽크가 맞아 보인다.

참고로, 성경이 말하는 헌금 원칙은 철저하게 리얼 모드이다. 빚을 내거나, 없는 돈을 미리 작정하는 보호 모드 가상 메모리 같은 개념은 없다.

5. 보안 정책

오래된 일이긴 하지만 마이크로소프트는 회사가 돌아가는 방식이 2002년 1월 1일 이전과 이후가 서로 싹 달라졌다. 더 엄격한 보안 정책과 더 일관성 있는 제품 지원 주기 정책이 적용되기 시작했기 때문이다.
일단, 이때를 기점으로 해서 모든 마소 제품에서 이스터 에그가 사라졌다. 문서화되지 않은 특정 동작을 하면 프로그램 개발자 명단이 나타나고 숨겨진 게임이나 애니메이션이 뜨는 것 말이다.

그리고 (a) 일반 지원은 제품 출시 후 몇 년 또는 차기 버전이 나온 지 몇 년 중 짧은 기간까지, (b) 연장 지원은 일반 지원이 종료된 뒤부터 5년간.. 요런 식의 규정이 추가되어 오늘날에 이르고 있다. 그와 함께 도스, Windows 3.x/95 같은 구닥다리 제품들은 지금까지 알음알음 지원되어 오던 것이 2001년 12월 31일을 끝으로 지원이 완전히 끊겼으며, 공식적으로 abandonware 판정을 받았다.

인터넷 덕분에 소프트웨어를 배포하는 데 물리적인 장벽은 사라지다시피한 반면, 원격으로 프로그램을 조종하고 컴퓨터를 장악할 수 있는 보안 위협이 커졌기 때문이다. 결국 소프트웨어라는 건 한번 만들고 나서 끝인 게 절대 아니라 계속해서 지원을 해야 하며, 굳이 기능과 컨텐츠에 아무 변화가 없더라도 보안 취약점이 발견되면 계속해서 고치고 지원해 줘야 하는 반제품 정도로 위상이 바뀌었다.

그러니 소프트웨어의 판매 비용에는 이런 잠재적 사후 지원 비용도 포함되어야 하며, 지원을 한도 끝도 없이 영원무궁토록 해 줄 수는 없으니 구체적인 기간이 법적으로 명시될 필요도 생긴 것이다.

그 뒤로 마소에서는 2000년 중반에 프로그램 개발의 기본 블록이라 할 수 있는 C 라이브러리에서부터 보안 결함을 잡겠다고 strcpy, gets 같은 함수들에 칼질을 가했으며, Visual Studio 2005에서 이를 최초로 적용했다. 컴파일러에 /GS 같은 검사 기능도 추가했다.
재래식 도움말인 WinHlp32 엔진은 너무 구닥다리 기능으로 전락한 데다, 이런 새로운 보안 기준을 충족하게 개량을 하기에는 가성비가 너무 안 맞는 관계(시간과 예산..)로 Windows Vista에서부터는 짤렸다.

Vista에서는 프로그램의 실행 주소를 그때 그때 랜덤화하는 보안 기능이 추가되기도 했다. Windows는 애초에 position independent code를 쓰지도 않는 운영체제인데, 오로지 보안을 위해 그 이념을 근본적으로 부정하고 성능을 희생하는 기능을 넣은 것이다.

이렇게 런타임 환경에다가는 예측 불가능한 요소를 일부러 가미한 반면, 빌드 타임 환경에는 정반대의 정책이 적용되고 있다. 같은 소스 코드를 같은 컴파일러 제품으로 빌드했으면 시간과 장소를 불문하고 바이너리 수준에서 언제나 완전히 동일한 실행 파일이 나오게 하자는 것이다. 이름하여 Reproducible builds이다.

단순히 생성되는 기계어 코드를 넘어서 객체들의 명칭과 배열 순서라든가 내부적으로 생성되는 각종 시그니처까지 완전히 똑같게.. 상상할 수 없을 정도로 다양한 환경에서 실행되는 프로그램을 테스트· 디버깅 할 때 문제를 재연하기 어렵게 만드는 변화 요인를 털끝만치라도 줄이겠다는 의도로 보인다.

시범타로 예전에 실행 파일의 헤더 차원에서 timestamp가 들어가던 곳에도 Windows 10에 들어가는 바이너리들은 그냥 고정된 hash값으로 바뀌었다고 한다. 시간이야 난수의 씨앗으로도 쓰일 정도로 매번 달라지는 값이니 말이다.
이렇게 21세기 들어서 세월이 흐를 수록 마소는 내부의 보안 정책이 갈수록 엄격해지는 것이 느껴진다. 그 와중에 실행 주소 랜덤화와 Reproducible builds라는 두 이념이 본인이 보기에 흥미로운 대조를 이루는 듯해 보였다.

Posted by 사무엘

2018/04/13 08:25 2018/04/13 08:25
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1478

코딩을 하다 보면 한 자료형(타입)에 속하는 값 내지 개체를 다른 타입으로 변환해야 할 때가 있다. 아주 직관적이거나(C에서 정수와 enum), 작은 타입에서 큰 타입으로 가기 때문에 정보 손실 염려가 없는 상황에 대해서는 해당 언어의 문법 차원에서 대입이나 함수 인자 전달이 곧장 허용되곤 한다. 바이트 수가 대등하더라도 signed보다는 unsigned가 더 큰 타입이고, 정수보다 실수가 더 큰 타입으로 여겨진다.

그렇지 않고 정보 소실의 여지가 있거나 서로 호환되지 않는 타입에다 값을 집어넣는 건 컴파일러의 경고나 에러를 유발한다. 이 상황에 대비하여 프로그래밍 언어들은 여러 형변환 함수들을 제공하는데, 씨크하게 괄호 안에다가 타입 이름만 달랑 쓰면 형변환 연산자로 인식되는 C/C++이 여기서도 참 유별난 면모를 보이는 것 같다.

형변환이란 건 값의 초기 타입과 목적 타입이 무엇이냐에 따라 내부에서 벌어지는 일이 매우 다양한 편이다.

(a) 없음: LPARAM에서 void*, void*에서 char* 같은 무식한 형변환은 소스 코드상의 의미만 매우 과격하게 바뀔 뿐, 내부적으로 행해지는 일은 전무하다! (클래스 상속 관계를 신경쓸 필요 없는 간단한 것 한정으로)

(b) 그냥 뒷부분 짜르거나 늘리기: int와 char 사이의 변환

(c) 정수와 실수 사이를 변환: 내부적으로 일어나는 일이 분명 간단하지는 않지만, 요즘은 이런 건 CPU 명령 한 줄이면 바로 끝난다. x86 기준으로 cvtsi2sd이나 cvttsd2si 같은 인스트럭션이 있다.

(d) 고정된 오프셋 보정: 단순한 형태의 다중 상속에서 제n의 부모 클래스 포인터를 얻으려면 이런 보정이 필요하다.

(e) 함수 호출: 해당 클래스에다 사용자가 구현해 놓은 operator 함수가 호출된다. 사실, 100과 "100"처럼 숫자와 문자열 사이의 변환도 컴퓨터의 관점에서 보면 이 정도의 cost가 필요한 작업이다.

(f) 상속 계층 관계 그래프 순회: 이건 객체지향 이념 때문에 형변환 연산이 언어 차원에서 가장 복잡해지는 상황이다. 위의 1~5와는 근본적으로 차원이 다르다. 가상 상속 체계에서 부모 클래스를 찾아가거나 dynamic_cast 형변환을 하려면 자기의 타입 정보 metadata를 토대로 주변 클래스 계층 그래프를 O(n) 시간 동안 순회해야 한다(n은 상속 단계).
사용자의 코드가 아닌 컴파일러의 코드만 실행됨에도 불구하고 런타임 가변적인 반복이 발생할 수 있으며, 객체와 메모리 상태가 어떤지에 따라서 형변환 결과가 dynamic하게 달라질 수 있다!

내부적으로 벌어지는 일은 대략 저렇게 분류 가능하고, 겉으로 소스 코드의 의미 차원에서도 형변환의 성격을 몇 가지로 분류할 수 있다.
처음에 C++에는 그냥 C-style cast밖에 존재하지 않았다. 그런데 그랬더니 형변환 연산이 발생하는 부분만 검색으로 뽑아내는 게 어려웠고, 또 단순한 형변환과 좀 위험한 형변환 같은 걸 따져 보기도 어려웠다. 표현 형태가 괄호와 타입 이름이 전부이니까..

그래서 1996~97년경, C++98의 발표를 앞두고 C++에는 길고 굉장히 기괴해 보이지만 용도별로 세분화된 형변환 연산자가 4종류나 추가되었다. namespace, bool, explicit, mutable 이런 키워드들과 같은 타이밍에 도입되었다. C++이 숫자는 몽땅 machine-word int로 어영부영 때우려던 C스러운 사고방식을 탈피하고, 예전에 비해 나름 type-safety를 따지기 시작한 그 타이밍이다. (예: C와 C++에서 sizeof('a')의 값의 차이는?)

새 연산자들은 모두 *_cast로 끝난다. 옛날의 재래식 형변환은 (NEWTYPE)value라고 썼고 C++ 문법으로는 NEWTYPE(value)도 허용되는 형태였다(NEWTYPE이 딱 한 단어 토큰으로 떨어지는 경우에 한해서).
그에 비해 새 연산자들은 *_cast<NEWTYPE>(value)라고 쓰면 된다. < > 안에다가 타입 이름을 쓰는 것은 템플릿 인자 문법에서 유래되었는데 나름 직관적이고 적절한 활용 같다.

1. static_cast

얘는 일상적으로 가볍고 큰 무리 없이 일어나는 일반적인 형변환을 거의 다 커버한다. (1) 큰 타입에서 작은 타입으로(실수에서 정수, UINT에서 int, long long에서 int 등..), 그리고 (2) 범용적인 타입의 포인터에서 더 구체적인 타입의 포인터로(void*에서 타 포인터, 기반 클래스*에서 파생 클래스 포인터) 말이다. 이게 대부분이다.

그리고 형변환 operator 호출이라든가, 다중· 가상 상속으로 인한 포인터 보정도 언어에서 보장돼 있는 메커니즘이므로 알아서 처리해 준다. 정말 대부분의 상황에서 앞서 나열했던 (a)에서 (f)까지, C-style cast를 대체할 수 있는 무난한 연산자이다. 단, f에서 typeid와 RTTI까지 동원되는 제일 비싸고 난해한 기능은 없으며, 이건 나중에 설명할 dynamic_cast가 전담하는 영역이다.

2. const_cast

얘는 값이 아니라 포인터/참조자형에서 C/C++ 특유의 한정자(qualifier) 속성만을 제거해서 더 범용적인 포인터로 만들어 준다. 그러므로 용도가 아주 제한적인 형변환 연산자이다.
C++에서 공식적으로 제공되는 qualifer는 const와 volatile이 있다. 이런 한정자는 가리키는 대상 타입과는 아무 상관 없고, 포인터를 이용해 그 메모리를 접근하는 방식 차원에서 제약을 부여할 뿐이다. 전자는 읽기 전용 속성이고, 후자는 멀티스레드에 의해 값이 언제든 바뀔 수 있음을 대비하라는 최적화 힌트이다.

Visual C++에는 __unaligned라는 확장 키워드도 저것들과 동급인 한정자이다. 이 포인터는 machine word 단위의 align이 맞춰지지 않은 주소가 들어올 수도 있으니 그렇더라도 뻗지 말고 보정하라는 뜻이다(성능 오버헤드 감수하고라도). align 보정을 알아서 너무 잘 해 주고 있는 x86 계열은 전혀 해당사항이 없고, 과거에 IA64를 지원하던 시절에 필요했던 키워드이다. 이것도 포인터 한정자 속성으로서는 굉장히 적절한 예이며, 이런 속성들을 const_cast로 제거할 수 있다.

3. reinterpret_cast

이건 의미론적으로는 제일 무식하고 생뚱맞고 위험하지만 내부 처리는 제일 할 것 없는 형변환 전문이다. (1) 정수와 포인터 사이를 전환하는 것, 그리고 (2) 서로 관련이 없는 타입을 가리키는 포인터끼리 전환하는 것.. 어디 범용적인 함수나 메시지로부터 아주 polymorphic한 데이터를 전달받아서 처리할 때에나 불가피하게 쓰일 법하다.

void*를 char*로 바꾸는 건 static_*으로도 되고 reinterpret_*으로도 된다. 하지만 const char*를 char*로 바꾸는 것은 static_*나 심지어 reinterpret_*로도 안 되고 반드시 const_*로만 해야 한다.
그런데 그래 봤자 reinterpret_*과 const_*는 어떤 경우에도 실질적인 내부 처리는 (a)뿐인(= 없음).. 참 허무한 연산자이다. 실질적인 처리가 없지만 이 숫자값을 해석하는 방식을 변경하는 이유는 분야별로 여럿 존재할 수 있다는 뜻이다.

재래식 C-style cast는 따지고 보면 1~3을 그냥 싸잡아서 구분 없이 수행해 준다. 그런데 가끔 드물게 다중· 가상 상속 관계의 타입 포인터끼리 형변환을 할 때 정석대로 보정 연산을 거친 포인터를 원하는지(static_*), 아니면 이것도 아무 보정 없이 동일한 메모리 주소에서 타입만 바꿔서 해석하고 싶은지(reinterpret_*) 모호해질 때가 있다.

이런 문제도 있고, 또 C++에다가 좀 제대로 된 객체지향 언어의 기능을 뒤늦게 갖추려다 보니 새로운 형변환 메커니즘이 필요해졌다. 이쯤 되니까 형변환 연산자도 별도의 예약어로 도입해서 구분하지 않고서는 도저히 버틸 수 없는 지경이 됐다. 그럼 다음으로 제일 괴물인 형변환 연산자에 대해서 살펴보도록 하자.

4. dynamic_cast

가상 함수를 쓰면 기반 클래스의 포인터를 주더라도 자신이 실제로 속한 파생 클래스에 해당하는 멤버 함수가 알아서 호출된다.
그리고 다중 상속 때 가상 상속을 쓰면, 여러 부모 클래스들이 동일한 조부모 클래스로부터 상속받았을 때 공통 조부모가 한 번만 상속되는 마술(?)이 일어난다. 물론 객체지향 언어에서 유연한 코드 재사용성을 보장하는 모든 마술에는 그에 상응하는 성능 오버헤드가 대가로 따른다는 점은 감안할 필요가 있다.

그런데 과거의 C++은 상속과 함수 호출에서 이렇게 언어 차원의 동적 바인딩이 지원되는 것과 대조적으로, 형변환에는 "동적 바인딩 + 무결성"을 보장하는 메커니즘이 딱히 없었다. 이놈이 A의 파생 클래스이긴 하지만 더 구체적으로 A의 자식들 중에 B가 아닌 C의 파생 클래스가 진짜로 맞는지, 이 멤버에 접근하고 이 함수를 호출해도 안전하겠는지 말이다.

C++은 void*가 있을지언정, 언어 차원에서 모든 클래스들의 공통 클래스(Java의 Object 같은)라는 개념이 없다. 그리고 클래스 내부의 vbtl에 직접 접근한다거나, 가상 함수의 포인터 값을 보고 클래스 종류를 판별할 수 있을 정도로 C++이 ABI가 몽땅 왕창 노출돼 있느냐 하면 그렇지도 않다(겉에는 공통의 썽킹 함수의 주소만 노출돼 있기 때문). 뭔가 어정쩡하다.

그러니 MFC 같은 옛날 라이브러리들은 자체적으로 CRuntimeClass 같은 타입 메타정보를 구비하고, 이놈이 CObject의 파생이긴 하지만 특정 클래스의 파생형이 정말 맞는지 런타임 때 확인하는 함수를 자체적으로 구현해야 했다.
C++이 아무리 C의 저수준 고성능 제어 이념을 계승했다 해도 명색이 객체지향 언어인데 그런 기능조차 없는 건 좀 아니라 여겨졌는지, 훗날 언어 차원에서 타입 식별 정보와 전용 형변환 연산자가 도입됐다. 그 결과물이 바로 dynamic_cast이다.

함수도 virtual, 상속도 virtual인 걸 감안하면 얘의 이름은 기술적으로 virtual_cast라 해도 과언이 아닐 듯하다. 하지만 static_* 이라는 단어가 이미 있으니 그것보다 더 값비싼 형변환이라는 의미로 dynamic_*이라는 이름이 최종적으로 붙었다. static_*은 이놈이 진짜 조상 관계가 맞는지 확인하지 않고 그냥 O(1) 복잡도짜리 기계적인 오프셋 보정만 해서(필요한 경우) 파생 클래스로 형변환해 주는 반면, dynamic_*은 타입 식별자를 직접 확인까지 한다는 것이다.

가상 함수의 구현을 위해서는 함수 포인터 테이블과 테이블의 포인터(멤버)가 필요하고, 가상 상속의 구현을 위해서는 기반 클래스의 포인터(멤버)가 필요하다. 그것처럼 동적 바인딩 형변환을 구현하려면 클래스 이름과 계층 관계를 기술하는 메타데이터와 함께 그놈의 포인터(멤버)가 필요하다.

가상 함수와 가상 상속 지원을 위한 데이터는 비공개로 꽁꽁 감춰져 있는 반면, 동적 바인딩 형변환을 위한 타입 식별자 데이터는 공식적으로 스펙이 공개돼 있고 일반 프로그래머들이 언어 요소를 통해 접근할 수 있다. 일명 RTTI (run-time type info)이다.
#include <typeinfo>를 한 뒤 typeid() 연산자로 const type_info&라는 구조체, 아니 클래스를 들여다보면 된다. typeid는...

  • sizeof와 마찬가지로 오버로딩 할 수 없다.
  • sizeof와 마찬가지로 타입 이름과 일반적인 값을 모두 받을 수 있다. 단, sizeof는 값이 올 때는 괄호를 생략할 수 있는 반면, typeid는 그렇지 않아서 언제나 뒤의 피연산자를 괄호로 싸야 한다.
  • sizeof는 결과값이 무조건 정적 바인딩으로 구해지는 반면, typeid는 바인딩이 정적과 동적 사이에서 어정쩡하다. 피연산자가 타입 이름이거나, 값이더라도 int 같은 primitive type 내지 상속이고 가상 함수고 아무것도 없는 구조체라면.. sizeof와 마찬가지로 수식을 실제로 evaluate하지 않는다. 그러나 뭔가 상속 관계 규명이 필요하다 싶은 개체라면 런타임 계산이 행해진다.

쉽게 말해 typeid는 MFC로 치면 obj->GetRuntimeClass()와 RUNTIME_CLASS(classnam)의 역할을 혼자 모두 수행한다는 뜻이다.
그럼 관련 타입들에 대해 DECLARE_DYNAMIC 내지 IMPLEMENT_DYNAMIC도 어딘가에 행해져야 할 텐데, 그건 C++ 컴파일러가 typeid 연산자가 쓰인 곳을 총체적으로 따져서 알아서 처리해 준다.

이런 RTTI 기능은 대다수의 C++ 컴파일러에서 사용 여부를 옵션으로 지정할 수 있게 돼 있다.
RTTI를 사용한 상태에서 dynamic_cast를 사용하면 실제 타입이 그게 아닌데 그 파생 타입으로 형변환을 시도하는 경우.. 피연산자가 포인터였다면 NULL이 날아오고, null이 가능하지 않은 참조자를 줬다면 예외(exception)를 날리게 된다.
이것도 다중 상속까지 생각한다면 상속 관계 그래프를 타고 오프셋을 보정하는 알고리즘이 가히 판타지가 될 것 같다.

이상.
객체지향 언어라는 게 그냥 구조체에다가 this가 자동으로 전달되는 함수가 같이 딸려 있고, 상속에 다형성 정도 지원되는 게 전부라고 생각하는 게 얼마나 순진한 생각인지 알 것 같다.
PC 환경에서 C를 초월하여 C++이 보급된 게 1990년대 초쯤인데, 이 무렵에 템플릿과 다중 상속도 도입됐다. 처음부터 있었던 게 아니다. 그리고 그때부터 C++은 뭐랄까, 괴물 같은 복잡도를 자랑하는 치떨리는 언어로 변모한 것 같다.

구리고 지저분한 면모가 많지만 그래도 그 정도 객체지향 이념에다가 고성능 저수준 제어까지 이만치 잡은 건 얘가 C++이 가히 독보적인 것 같다.
그러니 형변환 연산자도 언어를 따라 이렇게 복잡해진 것이다. 일례로, Java는 final이 있을지언정 포인터도 없고 const니 volatile 같은 건 신경 쓸 필요도 없는데 뭐 저런 구분이 필요하겠는가? 그러니 지금도 여전히 C-style cast만으로도 충분한 것이다.

Posted by 사무엘

2018/03/25 08:30 2018/03/25 08:30
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1471

1. Java 언어가 쓰이는 곳

Java는 C++과 달리 로컬 환경용으로는 그다지 재미를 못 본 언어 및 런타임 환경이다.
콘솔(게임기 말고, 명령 프롬프트..)용 프로그램이 Java로 만들어진 건 거의 못 봤다. 오히려 Java는 한때는 표준 입력으로부터 숫자 한 줄 입력받는 것조차도 온갖 패키지를 import하고 예외 처리 try catch까지 갖춘 뒤에야 가능한 불편한 언어였다.

GUI(프레임워크 이름이 Swing이던가?)로 넘어오면 VirtualBox나 OpenOffice처럼 일부 크로스 플랫폼 프로그램이 GUI 껍데기를 Java로 만든 경우가 좀 있다. (Windows 한정으로는 C#의 Windows Form와 경쟁?) 아, 직장에서 일정 관리 용도로 사용하는 ProjectLibre도 흔치 않은 Java 기반 로컬 GUI 프로그램이다.
하지만 Java가 제공하는 GUI는 외형이 네이티브 GUI에 비해 이질적이고 느리고 런타임 오버헤드가 커서 범용성 말고는 가성비가 좋지 않았다.

한편, Java는 애플릿(Applet)이라는 이름으로 웹(클라이언트)에서 돌아간 적도 있다. 웹에서 꽤 고차원적인 수학 그래픽, 화면 왜곡과 전환 애니메이션, 시뮬레이션(특히 교육용) 등을 출력할 목적으로 플래시와 경쟁하는 구도로 한때 쓰였으나.. 그것도 다 지나간 일이다. 현재는 망했으며, 개발사로부터 지원도 끊긴 지 오래다.

오늘날 Java는 로컬 PC가 아닌 안드로이드 앱, 그리고 jsp 기반 서버사이드 언어(웹 서버)로 회생해 있다.
뭐, Java 런타임을 컴퓨터에 설치해 보면 설치 중에 '전세계 수십억 개의 기기가 Java를 기반으로 돌아가고 있습니다'라고 자랑하는 문구가 뜨기도 하는데, 그것도 임베디드보다는 모바일을 말하는 게 아닌가 싶다.

Java는 오늘날 그렇게 됐고 로컬 PC 환경에서는 지금도 수많은 프로그래밍 언어들이 난립해 있지만, 웹에서는 그야말로 Java에서 이름만 빌려온 JavaScript로 대동단결 천하통일이 이뤄진 게 신기하기 그지없다.

2. 새로운 언어

애플에서 Objective-C에 한계를 느끼고 Swift라는 언어를 만들었더니,
그에 질세라 안드로이드 진영에서도 Java 대신 '코틀린'이라는 완전히 다른 언어를 만들었다. 왕년에 C++ 컴파일러를 열심히 만들었던 사람이 D 언어를 개발했듯, Java 기반 안드로이드 개발 환경을 열나게 만들었던 JetBrains라는 회사에서 전용 언어도 만들고 이를 Google에서도 채택한 것이다.

오늘날 베이직은 마소에서밖에 만들지 않는 언어가 됐고, 파스칼은 그냥 델파이의 전유물이 됐고 이제는 델파이라는 이름 자체가 RAD 툴 겸 프로그래밍 언어의 이름으로 등극했다. 언어가 워낙 많이 마개조됐기 때문이다. 또한 옵씨의 경우는 애초부터 애플이 언어에 대한 일체의 권리를 언어 고안자로부터 사 버려서 사유화했다.
저런 언어들에 비해, C/C++은 너무 심하게 파편화가 됐을지언정--언어 문법 자체보다는 라이브러리나 ABI 계층에서--, 특정 기업에 의해 좌지우지된다는 느낌은 상대적으로 덜 든다.

Visual Studio 등 요즘 IDE들은 프로젝트 전체에 존재하는 클래스들을 쭉 보여주는 기능이야 당연히 기본으로 갖추고 있다. 그런데 클래스들을 namespace 계층 + 이름의 알파벳 순으로만 보여주는 게 아니라, 기반 클래스별로 분류해서 보여주는 기능이 있으면 프로젝트들의 성격을 파악하는 데 더 도움이 될 것 같다. 그러면 그 클래스들의 성격을 파악할 수 있으니 말이다.

3. 간단한 자료형과 복잡한 자료형 or 클래스 객체

오늘날 객체지향을 표방한다는 많은 고급 언어들이 int 같은 (1) primitive type과 (2) 클래스 객체, 혹은 (1) 스칼라와 (2) 복합 자료형(리스트 같은)을 서로 구분해서 다루고 서로 다르게 취급한다. 함수 인자와 리턴값을 주고받을 때 (1)은 값을 그대로 전달하고 (2)는 메모리에 한 인스턴스만 둔 뒤 주소값만 주고받고 레퍼런스 카운팅을 하는 것이 대표적인 차이점 되겠다. Java, 파이썬 등 여러 언어들이 이런 정책을 사용한다.

순수 극한 객체지향 언어 중에는 1과 2의 구분조차 없애고 모든 것을 객체로 취급하여 타입 식별자라든가 기반 클래스 소속을 부여하는 물건도 있다. 하지만 그렇게까지 하기에는 속도와 메모리 오버헤드가 너무 크고 실용성도 떨어지니 많은 언어들이 최소한의 primitive type 정도는 허용하는 타협을 한다.

C++이야 어느 타입이건 어느 방식으로 전달할지(값, 주소/참조)를 몽땅 명시적으로 수동 지정이 가능하다. 그러나 포인터를 노출하지 않는 언어에서는 그런 구분이 프로그래머의 지정 없이 자동으로 행해진다. 포인터가 없는 언어라고 해서 포인터가 원래 하던 일 자체를 안 하는 것은 전혀 아니니까 말이다.

그런데 1과 2의 얼추 중간쯤에 속하는 물건은 문자열, 그리고 단순 구조체 정도다.
문자열이야 언어 차원에서 상수 리터럴도 존재하고 정말 기본 자료형과 복합 자료형의 중간에 속하는 독특한 물건인지라, 각 언어와 라이브러리마다 구현 형태가 제각각이다.

그리고 생성자, 소멸자, 상속, 가상 함수 따위 전혀 없고 레알 int 같은 primitive type의 묶음으로만 이뤄진 레알 C 스타일 구조체를 프로그래밍 용어로는 POD(plain old data)라고 한다. C++의 경우 POD 구조체만이 중괄호 { }를 이용한 멤버 별 값 초기화가 가능하다.
물론 POD도 개당 수십~수백 바이트를 차지하는 덩치 큰 놈이 될 수도 있지만 이는 논외로 하고..

단순 구조체를 클래스와 구분시킨 것은 C#의 신의 한수로 보인다. 사실, POINT, SIZE, COMPLEX(복소수)처럼 숫자 둘로만 달랑 이뤄진 간단한 구조체는 상속이 뭐고 없고 함수 인자도 그냥 값 형태로 전달하고 싶은 그런 물건들이니 말이다.

4. sizeof 연산자가 없음

개인적으로 Java를 쓰면서 C++에 비해 굉장히 특이하다고 오래 전부터 생각했던 점은..
가장 먼저, (1) int를 함수에다 주소/참조로 전달을 할 수 없어서 swap 함수를 구현할 수 없다는 것이다. int는 무조건 값으로만 전달되니 말이다.

(2) 클래스에 소멸자라는 게 없다 보니, 메모리는 GC 덕분에 뒷일 생각할 필요 없이 new만 늘어놔도 되는데 파일은 닫는 코드를 수동으로 매번 써 줘야 하는 게 처음엔 아주 웃기게 느껴졌었다.

(3) 그리고 끝으로.. sizeof 연산자가 없는 것에서 한번 더 경악했다. 수동 메모리 할당이 없고 포인터도 없고, sizeof마저 없다는 건 어떤 개체의 메모리 내부 덤프 같은 건 꿈에도 생각하지 말라는 얘기다.
primitive type이야 개별 크기가 어떤 기기의 JVM에서도 불변 동일하니(가령, int 4바이트, long 8바이트..) 굳이 sizeof에다 요청하지 않아도 되고..

하긴, 특정 개체의 메모리 주소 같은 걸 어디에다 낼름 누설해 줄수록 프로그램의 엔트로피가 커지고 Garbage collector가 추적해야 하는 영역이 넓어지고, 메모리 누수와 잘못된 포인터 에러가 날 확률이 높아지니.. 그런 건 최대한 애초에 할 필요가 없게 만들어야 할 것이다.
하지만 바이너리 덤프를 바로 못 하면 파일을 읽고 쓰는 작업도 좀 불편할 것 같다. 하다못해 과거에 베이직조차도 포인터는 없어도 숫자의 binary dump를 문자열 형태로 얻거나 그걸 디코딩하는 함수는 있었거늘 말이다.

C#은 Java처럼 가상 머신 기반에 자동 메모리 관리가 제공되는 언어이지만 이런 것들이 Java보다는 융통성이 있다. ref라는 키워드가 있어서 primitive type도 call by reference가 가능하다.
그리고 sizeof 연산자가 제한적으로 지원된다. primitive type과 단순 구조체 한정으로만 사용 가능하며, 자동 관리를 받는 자기 객체(managed class)에 대해서는 sizeof를 여전히 할 수 없다.

C#의 초창기 구버전에서는 아예 unsafe 코드 안에서만 sizeof를 사용할 수 있었지만 나중에 제약이 완화되었다.
그래도 C#과 Java 어느 경우든, sizeof라는 건 저수준 메모리 접근과 관계가 있는 기능이지, 가상 머신 고수준 코드와는 어울리지 않는 건 공통인 듯하다.

5. 객체의 배열

그러고 보니 Java는 클래스 객체들이 다 기본적으로 포인터 단위로 취급된다는 특성상, 객체의 동적 배열을 만드는 것도 좀 불편하다.

MyObject[] arr = new MyObject[n];

이건 MyObject들을 담을 배열 객체부터 하나 만드는 것이다. 그 뒤에 arr[0]부터 arr[n-1]에다가 MyObject의 인스턴스를 new로 할당하는 건 사용자가 또 직접 해야 한다.
C++이야 Java처럼 행동하고 싶으면 MyObject **arr을 하면 되고, 아니면 MyObject가 default 생성자만 있다면 곧장 MyObject *arr = MyObject[n]으로 객체의 배열을 만들 수 있으니 융통성이 있다.

그리고 한 객체가 여러 클래스들을 돌아다니면서 쓰이다 보면, 이 오브젝트가 값만 일치하는 게 아니라 본질적으로 그 오브젝트가 맞는지.. C++로 치면 주소값이 같은지, Java조차도 문자열은 equals 대신 ==를 써서 행하는 그 비교 대상값이 일치하는지.. 주소를 직접 확인하고 싶은 경우가 있다.

C/C++은 & 연산자가 있으니 일도 아닌 반면, Java는 디버거 창이 아닌 로그 확인을 위해서는 toString을 일일이 해 줘야 하는 것이 불편한 점으로 남는다.
포인터라는 게 위험하다고 직접 노출을 금기시하다 보니, 포인터로 직통으로 할 수 있는 일까지 좀 불편하게 바뀌는 건 어쩔 수 없나 보다.

6. 클로저

C++은 수동 메모리 관리뿐만 아니라 pointer-to-member(더 나아가 함수 포인터라는 것 자체까지)라는 것도 오늘날의 타 언어들이 제공하는 깔끔한 클로저와는 전혀 관계 없는 원시적이고 지저분한 물건이다. C/C++은(정확히는 C가) machine word를 정말 좋아하는 언어이며, '포인터' 하나만으로 간편하게 구현 가능하지 않은 요소는 쿨하게 제공 안 하는 게 전통적인 설계 철학이었다.

그러니 함수 안에 함수를 만드는 걸 지원하지 않으며(당대의 경쟁 언어이던 파스칼과 달리), C++도 클래스 안에 클래스, 함수 안에 지역 클래스 같은 건 전적으로 접근성 scope 구분만 할 뿐, 자기 밖에 있는 클래스 멤버나 함수 지역변수에 자동으로 접근하는 메커니즘 같은 건 제공하지 않는다. 즉, Java 용어로 말하자면 static class만 지원한다는 것이다.

C++만 쓰다가 Java나 Objective-C 같은 타 언어에서 this 내지 상부 클래스 멤버에 접근 가능한 함수를 스레드 콜백 등으로 자유자재로 넘겨줄 수 있는 걸 보니 아주 신기했다. 요즘은 함수형 언어 영향을 받아서 함수 몸체를 이름조차 안 붙이고 바로 넘겨줘도 되니 더 편하다.

이것도 타 언어들이 더 객체지향 철학에 따른 것이고, C++이 기괴하고 경직된 구조의 언어인 셈이다. 물론, C++의 사고방식은 저런 유연한(?) 함수 포인터(C++ 용어), 셀렉터(옵C 용어), 클로저(???)를 구현하기 위해 내부적으로 부과되는 시공간 성능 오버헤드가 무엇인지 생각하는 데는 도움이 될 듯하다.

C++은 저게 없는 대신에 P2M을 갖고 있는 셈인데.. C++의 또 다른 괴물 기능인 다중 상속과 연계하려다 보니 P2M도 도저히 '날포인터' 하나만으로 간편하게 구현할 수가 없어졌으니 참 아이러니이다. 이건 언어 설계 차원에서의 결함이나 마찬가지라고 봐도 할 말 없을 정도이며, 이와 관련하여 본인이 몇 년 전에 글을 쓴 적이 있다.

그나저나 Java의 상속 "extends A"는 C++로 치면 ": public A", 다시 말해 언제나 public 상속과 같은 것이겠지?
난 부모 멤버들에 대한 접근에 더 많은 제약을 가하는 private, protected 상속은 사용해 본 적이 없다.

7. 자동 메모리 관리, 동적 배열 등, 나머지 생각들

뭐, 직장에서 Java 개발도 좀 해 보니 깔끔한 1파일 1클래스 구조에다 헤더와 소스 구분이 없고 코드 파싱과 빌드가 정말 광속인 것(C++ 외의 타 언어들이 대부분 그렇지만), throw IllegalArgumentException("value must be >=0") 예외 처리가 사실상 assertion failure이나 마찬가지이니 assert(0 && "error") 이런 테크닉이 없어도 되는 것들은.. 마음에 든다.

ABI 계층이 파편화 없이 딱 잘 통합돼 있고, 무식한 헤더 파일 대신 패키지로부터 추출· 복원된 프레임워크 소스와 코딩 컨벤션.. 매번 다시 컴파일 되면서 특정 타입과 완전히 결합해 버리는 C++의 템플릿 대신에, 진짜로 유연한 void*를 캡슐화했다고 볼 수 있는 제네릭.. 이런 건 부럽기도 하고 현대의 프로그래밍 언어와 프로그래밍 환경이 얼마나 발전했는지를 뒷북으로나마 느낀다.

프로그래밍에서 "네이티브 코드 + 메모리 100% 수동 관리"(C/C++) 아니면 "가상 머신 + garbage collector 기반의 메모리 자동 관리"(C#/Java)의 차이는, 자동차 운전으로 치면 수동 vs 자동 변속기와도 비슷할 정도로 큰 차이인 것 같다. 자동차에서는 면허 조건이 달라질 정도로 큰 차이를 만들며, 프로그래밍에서도 뒷일 생각 안 하고 마음대로 new를 남발해도 되냐 그렇지 않느냐의 차이는 매우 크다.

(그럼 메모리가 자동 관리되는 언어에서는... 무슨 서버나 GUI 프로그램이 아니고 일체의 아이들 타임이 없이, 자기 할 일만 일괄 처리하고 끝나는 콘솔(명령 프롬프트)+단일 스레드 프로그램이라면 GC가 언제 어떻게 개입하여 동작하는지 의문이 든다. 뭐 그냥 메모리 할당 부분에서.. 자원 반납 없이 지금까지 메모리를 사용한 양이 도를 넘어선다 싶으면 그때 동작할 수도 있긴 하겠다.)

C++을 까는 사람들의 심정은 이해한다. 하지만 C++이 그렇게 지저분하고 자비심이 없는 대신에 어떤 넘사벽급의 강력한 네이티브 코드를 생산할 수 있는지, 그리고 Java가 편한 대신에 내부적으로 성능을 얼마나 많이 희생했고 런타임 오버헤드가 더해졌는지를 전부 논하지 않고 단편적인 비교만으로 언어 호불호를 논하는 것은 바람직한 태도가 아니라고 여겨진다. 둘 다 장단점이 있고 고유한 주 용도가 있는 법이다.

옛날에 잠깐 써 봤던 파스칼은 포인터도 있고 자유로운 call by reference도 지원했지만 그 포인터가 C처럼 막강하고 배열과 연계되는 건 절대 아니며, 동적 배열을 못 만들었다. 아무리 파스칼이 교육용 언어를 표방하고 만들어졌다 해도 베이직으로도 가능한 기능이 없는 건 말이 안 되니 저건 후대의 파생 언어에서 개선되었지 싶다.

Posted by 사무엘

2018/03/13 08:34 2018/03/13 08:34
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1467

« Previous : 1 : ... 5 : 6 : 7 : 8 : 9 : 10 : 11 : 12 : 13 : ... 31 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/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:
3041980
Today:
1607
Yesterday:
1700