남의 코드를 읽으면서 신기하게 느꼈던 점들을 다음과 같이 정리했다. 요즘 C++은 변해도 너무 많이 급격하게 변하고 있는 것 같다. 뭔가 다른 언어에 있던 기능이 비슷한 형태로 그대로 도입되는 편이다.

1. final과 override

본인은 C++에 클래스에 뭔가 제약을 가하는 기능이 부족한 편이라고 볼멘소리를 늘어놓아 왔다. 어떤 클래스가 더 상속이 되지 않게 하기, 이 함수가 더 오버라이딩이 되지 않게 하기, 대입이나 복제가 되지 않게 하기 등...
하긴, 이런 불평은 나만 하는 게 아니며, 오히려 나보다 더 깐깐한 불편러 PL 순수주의 성향인 사람도 많다.

함수 차원에서 제약을 가하는 것은 요즘 C++에서는 = delete 문법이 생겨서 불편이 많이 해소됐다.
그런데 이것 말고도 C++에 final과 override라는 조건부 키워드도 드디어 추가되었다니 참 놀랍다.

class Parent final { }

요렇게 해 주면 Parent를 기반으로 삼는 파생 클래스를 만들 수 없다. class Child: public Parent {} 이런 걸 시도하면 에러가 난다.
한편,

class Parent {
public:
    virtual void foo() {}
};
class Child: public Parent {
public:
    void foo() override {}
};

여기서 override는 Child의 foo가 기반 클래스의 foo를 재정의한다는 것을 나타낸다. 이 표기는 당연히 전적으로 optional이기 때문에 하든 안 하든 컴파일러의 코드 생성과 프로그램의 실행에는 아무 영향을 주지 않는다.

단지, 기반 클래스에 저런 함수가 없는데도 파생 클래스의 함수에 override가 선언돼 있다면 컴파일러는 에러를 찍어 준다. 그러므로 저걸 집어넣으면 내가 함수의 스펠링이나 매개변수를 실수로 잘못 넣었는지 여부를 곧장 알 수 있다.
그리고..

void foo() final;

final을 집어넣으면 짐작하다시피 이 함수는 파생 클래스에서 오버라이딩을 할 수 없게 된다. override와 final을 동시에 지정하는 것도 가능하다.

멤버 함수의 선언 뒤에다가 뭔가 속성을 지정한다는 점에서 const와 비슷해 보인다. 허나, 다시 말하지만 얘들은 전적으로 컴파일 때의 편의를 제공하는 hint일 뿐이다. 코드의 생성 방식이나 심지어 명칭의 decoration에도 전혀 영향을 주지 않는다.
const는 이거 지정 여부로 함수 오버로딩을 가능하게 하는 변별 요인이지만 override와 final은 그렇지 않다는 것이다. 그렇기 때문에 클래스 안에 함수의 선언부에다가만 지정하고 함수의 몸체 정의에다가는 생략도 가능하다. const는 그렇지 않다.

2. [[??]] 속성 지정자

함수의 선언에서 리턴 타입보다도 먼저 맨앞에 붙어 있는 [[nodiscard]] 이런 문구가 정체가 무엇인지 궁금해서 찾아 봤는데..
이 함수의 리턴값을 무시하지 않게 하는 자잘한 속성 지정자였다. 이 함수는 호출만 하고 리턴값을 무시하는 경우, 호출하는 쪽의 코드에다가 경고를 날리게 된다.

&를 두 개 써서 R-value 참조자라는 걸 추가했듯이, 여는 대괄호도 2개를 써서 저런 새로운 문법을 만든 것이다.
nodiscard 말고도 컴파일러의 최적화 전략에 단서를 제공하는 속성이 몇 가지 더 존재하며, C++ 언어의 버전이 올라가면서 아이템들이 추가되곤 했다.

함수의 선언에는 함수의 이름, 리턴 타입, 그리고 인자들 목록과 타입만 있는 줄 알았는데 그 사이에 calling convention 지정부터 시작해서 갖가지 추가적인 정보와 단서들을 지정하는 문법이 야금야금 도입돼 왔다.

extern "C"도 있고, 그리고 Visual C++이 전통적으로 사용한 방법은 __declspec(????)이다.
특히 deprecated는 [[]]와 __declspec()에 모두 존재해서 이제 기능이 완벽하게 겹치는 것 같다. 자기들이 필요하니까 마소에서 먼저 deprecated API를 지정하는 속성을 비표준 방식으로 추가했는데 그게 이제야 표준에도 도입된 셈이다.

그런데 C/C++은 태생적으로 함수를 선언할 때 function이나 그에 준하는 별도의 키워드를 두지 않았고, "리턴값 함수명(인자)"라는 문법 형태만으로 함수를 선언 및 정의할 수 있게 해 놓았다. 그러다 보니 그 사이에다 추가적인 정보를 집어넣는 문법이 좀 지저분해진 것은 피할 수 없어 보인다.
관점에 따라서는 아까 저 final, override 같은 힌트 속성도 [[]] 형태로 일관되게 넣을 수도 있어 보이지 않는가?

이런 부가 정보들을.. 단순히 경고만으로 끝나는 것, 컴파일 가능 여부에 영향을 주는 것(final), 코드의 생성 방식에 영향을 주는 것(호출 규약), 최적화 방식에 영향을 주는 것, C++ name mangling에 포함되는 것(const) 등으로 한데 분류해 보는 것도 의미가 있을 것 같다.

3. Variadic macro

요즘 C/C++에서는 #define 매크로 함수도 마지막 인자에다가 ...를 줘서 가변 인자 형태로 만들 수 있다.

이게 없던 옛날에는 가변 인자를 받는 함수를 매크로 함수로 간단하게 치환할 수 없어서 그냥 이름만 치환하는 매크로 상수를 써야 했다. 그리고 매크로 상수로는 가변 인자의 앞에다가 추가적인 인자를 삽입해서 다른 함수를 호출하는 식의 응용을 할 수 없었다. 하지만 이제는 그게 가능해졌다.

물론 가변 인자라는 건 근본적으로 C++의 이념과 그닥 어울리지 않는 물건이다. C++이 자체 제공하는 함수 오버로딩이나 default argument와 충돌하기 쉬우며, type-safety와 객체지향(특히 임시 객체에 대한 생성자/소멸자) 처리가 보장되지 않는다. 그러니 가변 인자로 주고받는 건 반드시 정수나 포인터 같은 단순 POD로 한정돼야 한다.

C++과 어울리는 물건이 아니다 보니 얘는 auto니 템플릿이니 하는 쪽에 관심이 온통 쏠려 있는 modern C++의 산물이 아니다. C99에서 맨 처음 도입됐던 것을 C++11이 나중에 같이 받아들인 것이다.
애초에 #define 전처리기도 C++보다는 꽤 C스러운 물건이다. 거기에 또 다른 C의 냄새가 풀풀 풍기는 ...이 잘 결합한 것 같다.

전처리기에는 ##이라는 연산자가 있어서 기존 명칭의 스펠링에다가 뭘 앞뒤로 붙여서 새로운 명칭을 만들게 해 준다.
그것처럼 가변 인자 매크로의 내부에는 가변 인자들 묶음을 한꺼번에 나타내는 __VA_ARGS__라는 특수한 매크로 상수가 정의되어서 사용 가능하다. 가변 인자 지원을 위해서 언어 문법이 확장이라면 확장된 셈이다.

사실, 이게 문법도 변형이 존재한다..

#define my_printf(a, ...)   printf(a, __VA_ARGS__) //A형
#define my_printf(a, args...)   printf(a, ##args ) //B형

지금은 A형이 표준인데 GNU C에서는 B형도 존재했는가 보다. Visual C++에서는 B형은 지원되지 않는다.

요즘 가변 인자가 활발하게 쓰이는 분야 중 하나는 printf 가변 인자 스타일로 문자열을 포매팅하는 디버그 로그 쪽일 것이다.
개발자가 잠깐 보고 마는 정보이니 문자열까지 쓸 것도 없이 간단히 char buff[256]으로 때워도 무관할 것이고 굳이 C++ string이나 stream을 쓸 필요가 없다. 더구나 이거야말로 디버그 빌드 여부냐에 따라 각종 조건부 컴파일과 전처리기 치환이 절실히 필요한 분야이니.. 가변 인자 매크로는 생각보다 개발 명분과 정당성이 풍부해 보인다.

추신:
글을 다 써 놓고 나중에 알고 보니 C++도 variadic macro와 비슷한 개념이 더 괴물 같은 형태로 템플릿에 이미 도입되었다.;; 이름하여 variadic template. template<typename... T> void foo(T.. args) {} 이러면 args가 __VA_ARGS__와 얼추 비슷한 argument pack 역할을 하게 된다.
이것 말고도 온갖 복잡한 용법이 많다. 이거 예시를 보이기 위해서 C++ 코드에다가 굳이 printf를 호출하려고 애쓰는 걸 보니 뭔가 느낌이 짠하다.

4. 현재의 함수 이름을 나타내는 매크로 상수

ANSI C에는 디버깅을 위해 __FILE__, __LINE__처럼 현재 컴파일 되는 파일 이름과 줄 번호로 유동적으로 치환되는 표준 매크로가 정의되어 있다. 이런 게 디버그 로그 내지 assert failure 매크로에서 즐겨 쓰인다.

그런데 현업에서는 이 뿐만 아니라 현재의 코드가 소속되어 있는 함수 이름 문자열을 나타내는 매크로도 쓰인다.
ANSI C 급의 원조 표준은 아니지만 #pragma once나 __super처럼 업계에서 오랫동안 사실상의 표준이나 마찬가지였던 물건이 있는데.. 바로 __func__이다.

얘는 C++11에서는 결국 정식 표준으로 등재되기도 했다. C++ 문법과 관계 있는 물건은 아니니 가변 인자 매크로처럼 C99에서 먼저 도입됐던 것이 추후 수용된 게 아닌가 싶다.
그리고 __FUNCTION__이라는 바리에이션도 __func__과 동일한 역할을 한다.

Posted by 사무엘

2021/08/12 08:33 2021/08/12 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1920

컴퓨터 소프트웨어계에는 이미 작성되어 있는 프로그램을 실제로 돌려 보지 않고(샌드박스 가상 머신 안에서..) 형태만 들여다보고는 버퍼 오버런이나 메모리 누출 같은 잠재적 위험성 및 논리 결함을 어느 정도 찾아 주는 '정적 분석'이라는 기술이 존재한다. 그 프로그램이 기계어 바이너리 형태이건, 고급 언어 소스 코드이건 형태는 무엇이건 상관없다.

그런데 정적 분석 툴은 그 누가 만든 것이라도 원천적으로 이론적으로 근본적으로 100% 정확하게 작동하지는 못한다.
이에 대해서 "아니 소스 코드가 무슨 자유 의지를 지닌 생명체도 아닌데 그 뻔한 로직을 분석해서 결과를 사전 예측하는 게 그렇게 어려운가? 단순히 소모하는 메모리와 계산량만 많아서 어려운 거라면 컴퓨터의 성능빨로 극복 가능하지 않은가? AI 기술을 접목하면 되지 않는가?" 처럼 생각하기 쉽다.

하지만 그렇지 않다. 그 말은 저런 차원이 아니다.
그런 함수는 단순히 현실적으로 구현하기가 어려운 정도가 아니라 논리 차원에서 모순에 빠지며 존재 불가능하기 때문이다. "모든 창을 막는 방패와 모든 방패를 뚫는 창 세트"와 동급으로 존재 불가능하다~! 창이나 방패의 제조 기술과는 무관하게 말이다.

가장~~ 원초적인 정적 분석 프로그램을 생각해 보기로 한다.
분석할 대상인 프로그램 코드, 그리고 그 프로그램에다가 넘겨줄 입력 데이터.
이 둘을 인자로 받아서 이 프로그램의 시시콜콜한 무슨 메모리 문제 따위를 진단하는 게 아니라..
이 프로그램이 무한 루프에 빠지지 않고 실행이 종료되기는 할지를 정확하게 판단해 주는 bool DoesThisProgramReturn(func, argument) 라는 가상의 함수 프로그램을 생각해 보자.

argument는 현실의 프로그램으로 치자면 명령 인자뿐만 아니라 프로그램이 파일이나 네트워크 형태로 읽어들이는 방대한 입력 데이터까지 모두 포함하는 개념이다. "일괄 처리 형태가 아니라 입출력이 실시간으로 들어오는 프로그램은요?" 이건 이 시점에서 그리 중요한 문제가 아니니 논외로 한다.
func는 뭐.. C/C++로 치면 기계어 코드를 가리키는 함수 포인터 정도로 생각하면 이해하기 편하겠다.

당연한 말이지만 저 함수 자체는 절대로 무한 루프에 빠지지 않고 언제나 유한 시간 안에 답이 나오는 게 보장된다. 무한 루프에 빠지는 프로그램을 의뢰했더라도 말이다. 그러므로 DoesThisProgramReturn(DoesThisProgramReturn, xxx)는 xxx로 무엇을 넘겨주건 그 정의상 리턴값이 언제나 true가 된다.

그럼.. 저 가상의 함수는 어떤 식으로 동작할지를 생각해 보자.
func가 가리키는 코드를 읽으면서 while(true); 같은 패턴을 발견한다거나,
더 구체적으로는 예전에 한번 거쳤던 state와 동일한 state로 이미 지났던 지점을 또 지나는 게 감지되면.. 이 프로그램은 실행이 끝나지 않는다는 결론을 내릴 수 있을 것이다.

이거 만델브로트(망델브로) 집합을 그릴 때 주어진 복소수의 발산 여부를 판별하는 것과 비슷하게 느껴진다.
배배 꼬인 복잡한 프로그램에서는 좀 어렵겠지만 그래도 도저히 불가능한 문제는 아니어 보이는데..??

하지만 튜링 기계는 우리가 흔히 생각하는 것보다 자유도가 더 높은 계산 모델이다.
메모리에 저장된 주소값에 해당하는 다른 메모리의 값을 마음대로 읽고 쓸 수 있을 뿐만 아니라(= 포인터) 거기 저장된 데이터를 코드로 간주해서 실행할 수도 있다(= 함수 포인터).

재귀 호출도 되고.. 또 앞서 살펴보았듯이 DoesThisProgramReturn 자신조차도 튜링 기계에서 실행되는 함수이기 때문에 DoesThisProgramReturn의 인자로 전달할 수 있다. 그리고 분석 대상인 타 함수가 얘를 또 호출할 수도 있다.
이런 상황까지 다 허용 가능해야 한다면 DoesThisProgramReturn의 존재 가능성은 굉장히 난감해진다.

아래와 같이.. DoesThisProgramReturn가 true라고 판정한(= 실행이 끝난다) func에 대해서는 "반대로" 자신이 무한 루프로 가 버리고, 실행이 끝나지 않는 함수에 대해서는 실행을 끝내는 HangIfReturns이라는 함수를 정의해 보자.

bool HangIfReturns(func) {
    if (DoesThisProgramReturn(func, func)) while(true);
    return true;
}

그러니 HangIfReturns(DoesThisProgramReturn)을 하면.. 얘는 무한 루프에 빠지게 된다.
DoesThisProgramReturn은 자기 자신에 대해서는 앞서 정의한 바와 같이 언제나 true를 되돌리고(= 늘 깔끔하게 실행 종료) if문을 만족하기 때문이다. 여기까지는 쉽다.

하지만 반대로 HangIfReturns가 DoesThisProgramReturn의 인자로 들어가면 어떤 일이 벌어질까? DoesThisProgramReturn(HangIfReturns, HangIfReturns)는 리턴값이 무엇이 되는 게 이치에 맞을까? 이제 좀 머리가 복잡해질 것이다.

DoesThisProgramReturn(HangIfReturns, HangIfReturns)가 true라면.. HangIfReturns 안의 if문은 true가 되므로 HangIfReturns은 무한 루프에 빠진다. 그러면 저 함수의 리턴값은 원래 false가 되어야 하게 된다.
반대로 저 리턴값이 false라면.. 역시 이제 HangIfReturns는 실행이 깔끔하게 종료되므로 저 함수의 리턴값을 정면으로 부정하는 결과가 나온다.

요컨대 HangIfReturns가 무한 루프에 빠지는지의 여부는 DoesThisProgramReturn의 리턴값에 따라 달라지는데, 이 과정에서 서로 물고 무는 구조적인 모순이 발생하는 셈이다.
이 모순은 DoesThisProgramReturn라는 함수가 존재한다는 가정으로부터 비롯되었다. 그러니 튜링 기계 하에서 다른 코드의 실행 종료 여부를 완벽하게 판단하는 코드를 똑같은 튜링 기계 기반으로 구현하는 것은 불가능하다는 것이 입증된다.

이 논리는 "정지 문제"(halting problem)이라고 불리며, 컴퓨터라는 기계의 계산 가능 범위를 고민하게 하는 매우 탁월한 통찰이다. 이걸 처음으로 생각해서 논문으로 발표한 사람이 바로 그 이름도 유명한 앨런 튜링이다.

과학 철학에서 "반증 가능한가", 천문학에서 "관측 가능한가"처럼.. 전산학에서는 "계산 가능한가, 튜링 기계를 돌려서 답을 구할 수 있는 문제인가"가 중요한 고민거리가 된다. 계산 자체가 이론적으로 가능해야 그 다음 관심사는 "실용적으로 유의미한 시간 만에 빨리 해결할 수 있는가?", 더 구체적으로는 "입력 크기 N에 관한 다항식 급의 시간 안에 해결 가능한가 (팩토리얼이나 지수 함수 급이 아니라)"라는 시간 복잡도가 될 것이다.

TSP(순회하는 세일즈맨) 문제 같은 NP-완전 문제는 이론적으로 알려진 시간 복잡도가 너무 높기 때문에 실생활에서는 적당히 성능이 좋은 다항 시간 근사 알고리즘이 쓰인다.
그래도 정지 문제는 3-SAT 문제라든가 NP-완전처럼 시간 복잡도를 따지는 증명보다는 덜 난해하고 직관적인 설명도 가능하기 때문에 수식 없이 블로그에다 증명 방식을 소개도 할 수 있다. 현실에서는 논리적으로 100% 완벽하고 헛점이 없고 100% 정확하게 동작하지는 못하지만 그래도 현실적으로 충분히 정확하고 속도도 적절한 각종 소스 코드 정적 분석 기능이 개발되어 쓰이고 있다.

Posted by 사무엘

2021/05/24 19:36 2021/05/24 19:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1891

1. 비트 연산 관련 버그

프로그래머가 살면서 설마 컴파일러의 버그를 볼 일이 얼마나 될까? 이건 마치 버스· 트럭· 택시 등 운전으로 먹고 사는 기사 아저씨가 잘 가다가 차량의 엔진 결함이나 급발진을 경험하는 것만큼이나 끔찍한 경험일 것이다.

본인은 최적화 옵션을 빡세게 주고 나면 Visual C++ 컴파일러가 비트 연산 쪽으로 유난히도 말귀를 못 알아먹는 현상을 종종 목격했다.
7년쯤 전에 VC++ 2010 기준으로 (1) bit rotate 연산을 <<, >> | 따위로 구현한 게 제대로 동작하지 않는 것을 목격했다. 그 함수만 #pragma를 줘서 최적화를 강제로 꺼야 오류가 발생하지 않았다.

그리고 2019년쯤에는 (2) WORD, BYTE 따위를 비슷한 연산으로 한데 합쳐서 DWORD를 만들려고 했는데.. 이것도 변수 내용을 강제로 로그를 찍으면 문제가 없지만 간단하게 값만 되돌리게 하면 틀린 값이 돌아왔다.
인라인 함수, 매크로 함수, 최적화 강제 해제 등 별별 방법을 써도 소용없어서 결국은 무식하게 memcpy로 값을 오프셋별로 강제 복사해서 문제를 회피해야 했다.

그 뒤, 19.5.x급으로 그 당시로서는 최신 업데이트가 적용됐던 Visual C++ 2019에서 더욱 황당한 일을 겪었다.
내가 하고 싶은 일은 8비트 char 값을 그대로 부호 없는 형태로만 바꿔서.. 즉, -3을 253으로만 바꾼 뒤 다른 산술 연산 처리를 하는 것이었다. 그런데 (3) 컴파일러가 말귀를 못 알아듣고 숫자를 32비트로 취급하면서 앞에 0xFFFFFF00를 제멋대로 붙였다.

숫자는 내가 기대한 것보다 엄청나게 큰 값으로 바뀌었으며, 프로그램은 이 때문에 오프셋 계산을 잘못해서 메모리 오류가 발생했다. 내가 아무리 강제 형변환 연산을 집어넣어 줘도 오류는 없어지지 않았다. 계산값에다가 원래는 할 필요가 없는 &0xFF 필터링을 강제로 하거나, 이 역시 최적화를 꺼야만 오류가 사라졌다. 이런..

이 세 사례는 모두 비트 연산 + 최적화와 관련된 컴파일러의 난독증이라는 공통점이 있었다. 2010으로 32비트 코드를 빌드하던 시절이나, 2019로 64비트 코드를 빌드하던 시절이나 마찬가지이니.. 딱히 버전과 아키텍처를 가리지도 않는 것 같다.

더 자세한 정황을 나열하지 못하는 이유는 이것들이 전부 방대한 회사의 코드를 취급하다가 발생한 일이기 때문이다. 그래서 동일 문제를 재연할 수 있는 최소한의 케이스를 따로 분리할 수가 없다. 그 함수만 텅 빈 프로젝트에다가 떼어내서 돌리면 당연히 문제가 발생하지 않는다.
하지만 동일 코드를 사용하여 macOS, 안드로이드 등 타 플랫폼에서 돌아가는 제품에서는 버그가 발생하지 않으니 이건 일단 Visual C++만의 문제라고 봐야 할 듯하다.

2. UTF-8 지원 여부와 미스터리한 오동작

Windows는 전통적으로 ANSI 인코딩(?) 천국이던 운영체제였다. 그래서 유니코드 자체는 진작부터 지원했지만 UCS-2 내지 UTF-16 같은 별도의 2바이트 단위 인코딩 형태로만 지원하는 것을 선호했다. 1바이트 단위 인코딩인 UTF-8의 형태로 지원하는 것에는 대단히 보수적이고 인색했다.

오죽했으면 Visual C++이 취급하는 리소스 스크립트 *.rc라든가 resource.h의 기본 포맷도 유니코드 기반으로 바뀌긴 했는데.. UTF-8이 아니라 UTF-16으로 바뀌었다. 거 참..

그래도 세월이 흐르니 마소에서도 대세를 거스를 수 없는지라, 명령 프롬프트에서 제한적이나마 65001 UTF-8 코드 페이지를 지원하기 시작했다. Windows 10 19xx 버전부터는 메모장이 기본으로 지정하는 텍스트 저장 인코딩이 UTF-8로 바뀌기도 했다.
심지어 Visual C++ 컴파일러 역시 UTF-8 인코딩의 소스 코드를 인식하기 시작했다. 단...!! 이건 2% 부족한 아쉬운 면모가 좀 있다.

바로.. 파일 앞부분에 BOM이 있을 때만 UTF-8로 인식한다는 것이다. 그렇지 않으면 그냥 ANSI이다.
소스 코드의 인코딩을 강제로 지정하는 옵션이 소스 코드 내부에 #pragma 같은 형태로 좀 있었으면 좋겠지만 그렇지는 않다. #pragma code_page라는 게 있긴 한데, C 문법을 일부 빌려 온 리소스 스크립트에만 쓰인다.
파일 내부 대신, 컴파일러의 옵션으로 /source-charset:utf-8 요런 게 존재하고, 줄여서 그냥 /utf-8이라고만 해도 된다.

생각해 보면 설정이 하나만 있는 것으로 충분하지 않다. 소스 코드 자체는 인코딩이 UTF-8인데 그 안에서 L로 둘러싸이지 않은 "한글"이라는 문자열 리터럴은 KS X 1001로, 즉 길이가 4바이트이고 전체 크기가 5바이트인 문자열을 의도한 것일 수 있다. 그렇게 실제로 의도된 인코딩을 지정하는 옵션은 /execution-charset이라고 따로 있으며, /utf-8은 두 charset을 모두 utf-8로 지정한 것과 같은 효과를 낸다.

그런데 컴파일러는 그렇게 인식시키면 되지만 에디터의 동작에 여전히 함정이 남아 있다.
BOM도 없고 딱히 한글· 한자 같은 문자도 없이 모든 문자열이 간단한 1바이트 숫자· 알파벳 따위로만 구성된 평범한 파일의 경우, Visual Studio IDE는 얘를 기본적으로 ANSI 인코딩 파일로 간주한다. 그 파일에 나중에 한글· 한자가 부주의하게 추가된다면 인코딩이 영락없이 잘못 지정될 수 있다. 이 기본 동작을 고치는 방법이 있는지는 난 아직 모르겠다.

그런데 그렇다고 BOM을 넣어 버리면..?? BOM은 Windows 동네에서나 통용되지, 리눅스 등 타 운영체제에서는 그냥 민폐 덩어리인 문자이다. 소스 파일의 앞에 저런 문자가 떡 있으면 컴파일러가 잘못 먹고 체하는 수가 있다.
그러니 한 소스를 여러 플랫폼에서 공유하는 경우, 모든 코드의 인코딩은 그냥 닥치고 BOM 없는 UTF-8로 통일하는 게 안전하다. 이 문제에 관한 한은 Visual C++이 타 빌드 툴들의 표준 관행에 맞춰 줘야 한다. BOM는 이식성을 저해하기 때문이다.

모종의 이유로 인해 Visual C++에서 소스 코드의 인코딩이 잘못 인식되면 빌드 과정에서 깨진 문자가 있다고 C4819라는 경고가 발생한다. 깨진 문자가 주석 내지 조건부 컴파일에 걸려서 어차피 빌드되지 않는 영역에 있을 때는 저게 딱히 문제될 게 없다. 단지, 문자열 리터럴 내부에 들어있던 한글· 한자가 깨지면 심각한 문제가 될 것이다.

그런데 내 경험상.. 주 번역 단위에 해당하는 소스 파일과, 걔가 인클루드 하는 헤더 파일 간에 인코딩이 다를 때도 상당히 골치 아픈 문제가 발생하곤 했다.
C4819 말고도 C4828이라고 파일의 줄 수가 아닌 오프셋 운운하면서 굉장히 기괴한 경고가 떴다. 최신 컴파일러에서는 이 경고가 삭제되었는지 조회되지도 않더라.

그리고 정말 믿을 수 없지만 컴파일러가 완전히 뜬금없는 에러를 내면서 동작을 멈췄다. 실제로 문법 오류가 전혀 없는 구문에서도 쓸데없는 에러가 발생했으며, 그 소스 파일에 실제로 존재하지 않는 칸 번호를 언급하기도 했다.
이렇게만 말하는 나도 황당하고 읽는 분들도 상황을 받아들이지 못하시겠지만.. 내가 실제로 겪은 상황이 저랬다.

이 역시 회사에서만 겪었기 때문에 정확· 엄밀하게 재연 케이스를 만들지는 못하겠다. 아까 얘기했듯이 (1) /utf-8 옵션을 global하게 준 상태에서 소스와 헤더 파일들의 인코딩이 충돌 난 것, 그리고 아마도 (2) precompiled 헤더를 쓰는 소스와 그렇지 않은 소스가 한 프로젝트 안에서 좀 뒤섞여 있는 것, (3) namespace와 using이 좀 복잡하게 얽혀서 인텔리센스도 오락가락 하는 상황인 것이 다 조금씩 영향을 주지 않았을까 생각된다.

이 난국은 모든 코드의 인코딩을 BOM 없는 UTF-8로 정리하고, 모든 코드에다가 한글로 dummy string을 만들어서 Visual Studio IDE가 파일을 ANSI (cp949) 인코딩으로 잘못 저장하는 일이 없게 조치를 취함으로써 해결되긴 했지만..
그때 그 문제가 왜 발생했으며 그 상황을 어떻게 재연할 수 있는지는 모른 채 미스터리로 남게 되었다.

회사에서는 길지 않은 기간 동안에도 이 정도의 이상한 버그를 몇 차례 경험했는데.. 개인적으로 날개셋 한글 입력기를 20여 년 가까이 만들어 온 동안은 컴파일러의 버그를 경험한 적이 거의 없다는 것이 참 신기하다. IDE야 불필요하게 다운되거나 뻗는 버그를 여럿 경험했지만 컴파일러가 문제를 일으킨 적은 없었다.
모든 코드가 깔끔하게 KS X 1001 레거시 인코딩이고, 회사 코드보다는 규모가 작고 모듈 구조가 깔끔하고, 전부 precompiled 헤더를 사용하기 때문이 아닌가 생각한다.

소스 코드의 인코딩이 UTF-8이 아니거나, UTF-8이더라도 앞에 BOM이 있는 것 자체를 경고로 처리하는 건 너무 과격할까? 그리고 #include에서 경로 지정을 /가 아닌 \로 한 걸 경고로 처리하는 옵션도 있으면 좋겠다. 이런 건 Windows 환경에서나 통용되지 밖에서는 전부 민폐 에러 요인이 되기 때문이다. 본인이 직장의 공동 작업 과정에서 종종 실수했던 적도 있는 사항들이다.

3. 인텔리센스의 오동작

끝으로, 이건 실제로 생성된 exe/dll의 동작과 관계 있는 치명적인 문제는 다행히 아니지만.. Visual C++ IDE가 텍스트 에디터에서 사용하는 인텔리센스도 일부 특이한 상황에서는 말귀를 못 알아듣고 오동작할 때가 있다.

본인이 겪은 경우는 클래스(가령 A)의 선언 내부에 MFC의 DECLARE_DYNAMIC 같은 복잡한 custom 매크로를 넣은 뒤, 곧장 private/public/protected 같은 접근 권한 지정자가 나올 때이다. 그러면 인텔리센스가 그 뒤에 이어지는 멤버 및 내부 enum/class (가령 B) 따위 선언을 파싱을 제대로 못 한다. ClassView를 보면 A의 멤버 목록에 B의 멤버들이 잘못 표시되며, B 선언 이후에 등장하는 A의 진짜 멤버들은 전혀 인식되지 않는다.

ClassView뿐만 아니라 텍스트 에디터에다 불러온 소스 코드에서도 각종 경고와 에러 밑줄이 A의 멤버들이 누락된 것처럼 쭈루룩 뜬다.
그렇기 때문에 A 클래스의 구현부에서는 인텔리센스와 자동 완성, 심벌 위치 조회 같은 기능들을 활용하지 못하면서 코딩을 꽤 불편하게 해야 한다.

이런 초보적인 문제는 Visual C++ 6 ncb 시절에나 보던 게 아니었나? 왜 발생하는지 모르겠다.
최신 업데이트를 적용한 Visual C++ 2019에서도 동일하게 발생한다. 본 컴파일러가 아니라 인텔리센스 컴파일러이니 딱히 특정 Visual C++ 컴파일러 툴킷만의 문제도 아닐 것이다.

뾰족한 해결책은 없고, 인텔리센스를 헷갈리게 하는 그 문제의 매크로를 클래스 선언의 맨 앞이 아니라 맨 뒤로 옮김으로써 문제를 회피할 수 있었다. 흠...

4. 도킹 하다가 뻗음

역시 컴파일러가 아닌 IDE 얘기이고, 옛날 버전에서만 발생하는 문제이기 때문에 지금 큰 의미는 없지만..
Windows 10 19xx대 버전부터인가 Visual Studio 2013 (그리고 아마 2015도)에서 각종 문서 편집 창이나 보조 윈도우(출력, 속성, 디버그 등등)를 어디에든지 도킹을 해서 붙이면 프로그램이 뻗어 버린다.

2010이 언제부턴가 실행될 때 Microsoft.Vsa.tlb 파일이 없다는 에러를 내는 것과 비슷한 현상인 것 같다. 그래도 얘는 정상 실행은 되고 프로그램 사용에 문제가 없는 반면, 저건 창을 내 마음대로 배치할 수 없게 만들고 프로그램이 뻗기까지 하기 때문에 상당히 심각한 문제이다.
저런 단순 UI는 운영체제건 VS건 한번 만들고 나서는 고칠 일이 없는 기능일 것 같은데.. 둘 다 내부적으로 뭘 건드리길래 이런 부작용이 발생하는 걸까..??

하긴, 더 옛날엔 Visual Studio 2005도 Windows Vista에서 실행하려면 sp1에다가 Vista 지원 추가 패치까지 설치해야 겨우 돌릴 수 있었다. 아래아한글 2005와 2007도 Vista 이후의 운영체제에서 실행하려면 업데이트부터 대판 설치해야 했었으니 이런 예가 전혀 없지는 않구나.

어떤 프로그램이 후대의 운영체제에서 단순히 GUI나 외형의 glitch 정도가 발생하는 걸 넘어 아예 뻗고 실행이 안 되는 건.. 대부분 보안 강화 때문이지 싶다. 문서화되지 않고 미래에 얼마든지 달라질 수 있는 특성이나 동작에 의존하게 프로그램이 만들어진 경우야 걔의 잘못이겠지만, 흔한 경우는 아닐 것이다.

Posted by 사무엘

2021/05/19 08:35 2021/05/19 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1889

프로그래밍에서 메모리를 가리키는 포인터라는 건.. 그 특성상 돌아가는 컴퓨터의 machine word와 크기가 동일하다. 하지만 현실에서 포인터(= 메모리 주소)를 구성하는 모든 비트가 골고루 쓰이는 일은 몹시 드물었다.

먼저, 컴퓨터의 실제 메모리 양이 포인터가 가리킬 수 있는 범위보다 훨씬 적다. Windows의 경우, 32비트 시절에는 user mode에서는 대부분의 경우 포인터의 상위 비트가 언제나 0이었던 것이 잘 알려져 있다(하위 2GB까지만 사용).
하물며 64비트는 공간이 커도 너무 크기 때문에 가상 메모리 관리 차원에서도 아직은 40~48비트까지만 사용한다. 상위의 무려 16비트가량이 쓰이지 않는다는 것이다. 램이 32GB여도 겨우 35비트면 충분하니까..

가난하고 배고프던 20세기 16비트 시절에는.. 반대로 포인터 하나만으로 겨우 몇백 KB~수 MB 남짓한 메모리도 한번에 다루지 못했다. 그래서 far 포인터니 huge 포인터니 별 삽질을 다 해야 했는데 그때에 비하면 지금은 격세지감이 따로 없다.

저렇게 상위 비트뿐만 아니라 하위 비트도 마찬가지이다. padding, align 같은 이유로 인해, 메모리 할당 함수의 포인터 리턴값이 홀수가 될 일은 일반적으로 없다. 아니, 겨우 2의 배수가 아니라 4나 8의 배수가 될 수도 있으며, 이 경우 하위 2~3개 비트도 0 이외의 값을 가질 일이 없게 된다.

그러니 포인터를 저장하는 공간에서 0 이외의 값이 들어올 일이 없는 비트에다가 자신만의 정보를 넣는 꼼수를 부리는 프로그램이 예로부터 줄곧 존재해 왔다.
이거 무슨 변태 같은 짓인가 싶지만.. 이제 막 32비트로 넘어가긴 했지만 아직 가정용 컴퓨터들의 평균적인 메모리 양이 수 MB대밖에 안 됐던 시절이 있었다. 이때는 메모리가 부족해서 하드디스크 스와핑이 일상이었다. RAM을 1바이트라도 더 아끼는 최적화가 필수였다.

가령, 다재다능한 자료구조인 빨강-검정 나무를 생각해 보자.
노드의 색깔을 나타내는 겨우 1비트짜리 정보를 위해서 굳이 bool 멤버를 추가하는 건 굉장한 낭비라는 생각이 들지 않는가? 단 1비트 때문에 구조체 패딩까지 감안하면 무려 2~4바이트에 달하는 공간이 매 노드마다 허비되기 때문이다.
안 그래도 노드의 내부엔 left/right 같은 딴 노드 포인터가 있을 것이고, 포인터 내부에 쓰이지 않는 1비트 공간이 있으면 거기에다 색깔 정보를 박아 넣고 싶은 생각이 들 수밖에 없다. 비트필드와 포인터의 union 써서 말이다.

물론, 그렇게 0으로만 채워지던 공간을 운영체제에서도 나중에 유의미하게 사용하기 시작하면.. 그 꼼수 프로그램은 재앙을 맞이하게 된다.
대표적인 예로 마소에서는 32비트 기준으로 사용자:커널이 통상적인 2GB:2GB가 아니라 3GB:1GB로 주소 공간을 분할하는 기능을 Windows에다가 추가했다.

이러면 사용자 모드의 포인터도 2GB가 넘는 영역에 접근할 수 있으며 최상위 비트가 1이 될 수 있다. 그런데 포인터의 최상위 비트를 자기 멋대로 사용하고 있는 프로그램은.. 뭐 메모리 뻑나고 죽을 수밖에 없다.
64비트 환경에서는 겨우 1비트가 아니라 상위 word 전체를 다른 용도로 전용해도 당장 이상이 없으며 이 추세가 앞으로 몇 년은 가지 싶다. 컴퓨터의 램이 256~512GB나 1테라까지 간다면 모를까..

요즘 컴퓨터야 메모리가 워낙 많고 풍족하니, 굳이 저런 꼼수를 동원하는 프로그램은 별로 없을 것이다.
하지만 저 때가 되면 또 꼼수 부리는 말썽꾸러기 프로그램과의 호환성 때문에 주소 공간을 옛날처럼 상위 16~32GB까지로 봉인하는 옵션 같은 게 또 등장할지도 모른다.;;; HIGH_DPI_AWARE처럼 LARGE_ADDRESS_AWARE 시즌 2 말이다.

여담이지만 Windows의 경우, 실행 파일은 시작 주소가 언제나 64KB의 배수 단위로 부여되기 때문에 HINSTANCE/HMODULE은 아래쪽은 무려 word 덩어리가 언제나 0이 된다. 이 특성을 이용해서 운영체제의 LoadLibraryEx 함수도 하위 몇 비트를 자기 마음대로 활용하기도 한다.

※ 나머지 메모

(1) unsigned 타입에 대해서 단항 연산자 -를 적용해서 -a 이런 값을 구하는 코드를 우연히 보고는 개인적으로 신박하다는 생각이 들었다. 흐음~ Visual C++의 경우 이건 원래 경고인데, 요즘 버전에서는 더 엄격하게 에러로 처리하는가 보다.
-a는 2의 보수의 특성상 ~a+1과(비트 not보다 1 크게) 완전히 동일한 효과를 내며, 앞에 0을 붙여서 이항 연산자로 만들어도 에러를 회피할 수 있다.

(2) ANSI C에서는 함수의 prototype을 선언할 때 매개변수 리스트에 타입만 써 넣고 이름을 빼먹으면 안 된다는 걸 최근에야 알게 됐다.
아니 도대체 왜..? 거기서 매개변수의 이름은 거의 잉여 옵션에 불과할 텐데.. void func(int);라고만 쓰면 틀리고 void func(int x);라고 아무 이름이라도 붙여야 된다는 것이다.
이건 먼 옛날에 C언어에서 void func(a) int a; 같은 구닥다리 문법이 쓰이던 시절의 잔재인 것 갈다.

Posted by 사무엘

2021/05/15 08:35 2021/05/15 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1887

컴퓨터그래픽에서 벡터 그래픽의 반의어로 픽셀과 비트맵을 다루는 체계를 래스터 그래픽이라고 흔히 부른다. 종이가 아니라 해상도가 상대적으로 낮은 모니터 화면이 주 무대이고, 면을 채우는 기본 단위가 scan line(주사선)이라는 관점에서 정립된 용어이다.

그리고 2D 비트맵(더 정확한 명칭은 래스터..?) 그래픽 API를 보면 어떤 플랫폼용 어떤 언어의 라이브러리이든지 점과 직선, 곡선을 그리는 함수가 있고, 사각형과 원을 그리는 함수가 있다. 이게 기본이다.
점이나 사각형이야 그리는 방식이 너무 trivial하니 제끼고, 원이나 곡선을 빠르게 그리는 원리는 기하 알고리즘의 일종으로 다뤄지기도 한다. 그 단순한 직선조차도 굵기가 2픽셀 이상이 되면 중심점을 생각해야 할 것이고, 무거운 부동소수점 연산 없이 anti-aliasing까지 하면서 그린다는 조건이 추가되면 결코 쉽지 않은 일이 된다.

그리기 기능 중에서 특정 픽셀부터 시작하는 flood fill은 무척 독특한 동작이다. 기하 알고리즘이라기보다는 스택 메모리를 동원해서 컴에게 길 찾기 재귀호출 노가다를 시키는 코딩의 영역이다. 빼곡한 미로의 내부에 있는 한 점에서 flood fill을 시켜 보면 이건 본질적으로 길 찾기와 다를 바 없다는 걸 알 수 있을 것이다.

글쎄, flood fill은 그래픽 에디터에서 사용자가 내리는 채우기 명령을 구현하는 형태로나 쓰이지, 직선과 곡선, 사각형과 원처럼 그림을 그리는 구성요소로서 프로그램이 내부적으로 사용할 일은.. 정말 아주 특수한 상황이 아니라면 없을 것이다. 도형 자체를 처음부터 내부가 채워진 형태로 그려야지, 도형의 윤곽만 그린 뒤에 도형 내부의 임의의 점을 따로 주고 채우는 건 몹시 비효율적이기 때문이다.

그래서 그래픽 라이브러리에는 다각형을 그리는 함수가 있다. 다각형의 경계선만 찍찍 그리는 것이야 LineTo만으로 얼마든지 할 수 있으므로, 이런 함수는 내부가 채워진 다각형을 그리는 것이 핵심이다. 그러니 이 함수는 다른 함수와 달리, 반드시 다각형의 꼭지점들이 담긴 배열을 전달받아야 한다.
옛날 도스 시절의 베이식은 타 언어들에 비해 그래픽 모드의 접근성이 좋았지만, 정작 다각형을 그리는 API는 없었다.

그럼 다각형을 채우는 기능은 어떤 방식으로 동작하는 걸까?
이걸 구현하기 위해서는 어떤 점이 다각형의 내부에 속하는지를 판단해야 한다. 더 나아가서 이 점에서 한쪽으로 scan line을 그어 나갈 때 어디까지가 동일하게 다각형의 내부 또는 외부인지를 판단해야 한다.

이걸 판단하는 방법은 의외로 간단하다. 그 점으로부터 아무 방향으로(예: x축 양의 방향) 한없이 직선을 그을 때, 그 선이 다각형을 구성하는 선분과 얼마나 몇 번이나 마주치는지를 판단하면 되며, 이걸 판단하는 방법도 크게 두 갈래로 나뉜다. 바로 (1) 홀짝 아니면 (2) 0여부이다.

홀짝법은 마주친 선분이 짝수 개이면 다각형의 외부이고, 홀수 개이면 내부라고 판단한다. 다시 말하지만 이 가상의 선은 정말 아무 방향으로나 그리면 된다. 다각형이 모든 방향으로 닫혀서 내부에 공간이 존재한다는 사실 자체가 이 판별법의 correctness를 보장해 준다.

0여부는.. 홀짝보다 더 절묘하다. 초기값이 0인 가중치라는 걸 두는데, 마주친 선분이 우리가 그은 가상의 선을 위에서 아래로 교차한다면 가중치에 1을 더한다. 그렇지 않고 아래에서 위로 교차한다면 1을 뺀다.
이렇게 해서 최종적으로 가중치가 양수든 음수든 0이 아닌 값이 나온 점은 다각형의 내부라고 간주하고, 0인 점은 외부라고 간주한다.

0이나 홀짝이나 그 말이 그 말 같은데.. 실제로 자기네 선분끼리 배배 꼬아서 교차하지 않는 일반적인, 평범한 오목/볼록다각형이라면 어느 판별법을 사용하든 결과에는 아무 차이가 없다.
하지만 당장 오각형 별표를 한붓그리기로 그린 궤적을 줘 보면 둘은 서로 차이를 보인다.

사용자 삽입 이미지
Windows API에서는 SetPolyFillMode라는 함수가 있어서 두 방식을 모두 사용해 볼 수 있다. 더 단순한 홀짝법이 ALTERNATE이고 기본값이다. 0여부는 WINDING... Windows 1.x 시절부터 존재해 온 오래된 고전 API여서 그런지, 매크로 상수의 앞에 접두사가 붙어 있지도 않다(PFM_* 같은?? ㅎㅎ).

오각형 별표에서 별의 중앙에 생긴 공간을 보면.. 그 옆으로 다각형 경계를 나타내는 선이 어느 방향이든 두 개가 존재한다(짝수). 그런데 이들은 방향이 둘 다 오르막 아니면 둘 다 내리막이며, 이 때문에 winding value는 nonzero가 된다. 그러니 ALTERNATE일 때는 이 공간이 비워지지만 WINDING일 때는 공간이 채워지는 것이다.

그 위의 더 복잡한 꼬인 사각형도 상황이 비슷하다. 잘 살펴보면 이 궤적도 홀수점이란 게 전혀 존재하지 않으며 한붓그리기가 가능하다.
그런데 WINDING일 때는 궤적이 꼬여서 생긴 내부의 사각형 공간 둘 중에서 좌측 하단 한 곳만 채워져 있다. 그 이유는 역시 저기서만 winding value가 nonzero이기 때문이다.

일반적으로 WINDING(0여부)이 판정하는 다각형 영역은 ALTERNATE(홀짝)의 상위 호환이다. ALTERNATE가 판정하는 영역을 100% 포함하면서 일부 영역을 추가적으로 더 판정한다는 뜻이다. 그렇다고 해서 모든 닫힌 영역을 한 치의 예외 없이 몽땅 내부라고 판정하는 건 아니다.

뭐.. 현실의 벡터 그래픽에서 이 따위 선끼리 교차하는 배배 꼬인 폴리곤을 생성하는 것은 애초부터 권장되지 않는 금지 사항이다. 가령, 속이 빈 오각별을 그리고 싶으면 저렇게 보이는 대로 삼각형 다섯 개로 풀어서 표현하라는 것이다. 윤곽선 폰트 등 벡터 그래픽 편집기들은 그렇게 폴리곤의 모양을 자동으로 수정해 주는 기능도 제공한다.
그러니 이렇게 fill mode의 차이점을 미주알고주알 관찰할 일이 현업에서는 거의 없을 것이고, 이런 건 그냥 학교에서 컴퓨터그래픽스 기초를 공부할 때 이런 방식도 있다는 걸 알기만 하고 넘어가면 될 것 같다.

하지만 그게 전부가 아니다. 다각형 채우기의 기능이 더 확장되면 다음 영역에도 도달하는데, 이때 fill mode의 차이점이 다시 드러나게 된다.

1. 여러 다각형을 한꺼번에 그리기
이건 내부에 구멍이 뚫린 다각형을 그릴 수 있다는 것에 의의가 있다. 구멍은 Polygon 함수를 연달아 호출하는 것으로는 표현할 수 없기 때문이다.

Windows에는 여러 다각형을 한꺼번에 그리는 PolyPolygon이라는 함수가 있다. 그런데 아까처럼 한 다각형에서 변들이 서로 교차하고 꼬였을 때뿐만 아니라, 변은 꼬이지 않았고 여러 다각형들의 영역이 서로 겹칠 때에도 fill mode의 차이는 유의미한 동작의 차이를 만들어 낸다.

사용자 삽입 이미지

위의 그림은.. 뭐 이론적으로는 한붓그리기가 가능하기 때문에 역시 꼬인 단일 다각형으로 궤적을 나타낼 수 있다. 하지만 앞서 예를 들었던 오각별이나 그 사각형 그림과 달리, 일부 점과 점이 겹치는 건 피할 수 없을 것이다. 무슨 말인가 하면, 저 궤적을 꼭지점 좌표의 배열로 기술했을 때, 4개의 선분과 만나는 점은 두 번 등장하는 부분이 생긴다는 것이다.

꼬인 단일 다각형이 아니라 영역이 일부 겹치는 사각형과 삼각형을 서로 떼어서 PolyPolygon으로 그린 경우.. ALTERNATE(홀짝)에서는 짝수 개의 다각형에 속하는 영역은 비우고, 홀수 개에 속하는 영역만 칠한다. 그러고 보니 동작이 뭔가 XOR스러워 보인다. 각 다각형들의 꼭지점이 기술된 방향은 어느 쪽이건 무관하다 (시계 or 반시계 방향)

그러나 WINDING(0여부)일 때는 그 특성상 방향이 같은 다각형들은 겹치더라도 영역을 모두 칠한다. 겉의 껍데기가 시계 방향이라면.. 그 안의 구멍은 반시계 방향으로.. 다른 방향으로 칠해져야 구멍이 비게 된다! 다시 말하자면, WINDING에서도 위의 그림의 왼쪽처럼 중앙이 비어진 그림을 그리고 싶다면 사각형과 삼각형의 좌표 방향이 서로 반대여야 한다.
꼬인 단일 다각형에서 fill mode의 차이점을 설명하는 프로그래밍 서적들이.. 다중 다각형까지 연계해서 동일 개념을 설명하는 경우는 내가 딱히 못 본 것 같다.

2. 직선뿐만 아니라 베지어 곡선까지 포함된 궤적의 내부를 채우기
위와 같은 구멍 감지에다가 곡선 지원까지 포함되면.. 이건 뭐 윤곽선 글꼴 래스터라이저가 번듯하게 완성된다. 물론 본격적인 폰트 엔진은 거기에다 작은 크기에 대비한 정교한 안티앨리어싱과 힌팅, 글꼴 글립 캐시, 더 나아가 복잡한 유니코드 문자 형태 분석까지 추가되는데 이것들 하나하나가 별개의 전문 영역일 정도이다.

FreeType 라이브러리는 그 중에서 제일 저수준인 그리기, 안티앨리어싱, 힌팅까지만 담당한다. 요즘 소프트웨어들은 글자 하나를 찍는 것도 겨우 8*16, 16*16 비트맵 글꼴 찍던 시절과는 차원이 다르게 더 복잡해져 있는 셈이다.
그건 그렇고.. Windows API에는 직선과 곡선이 포함된 도형을 한꺼번에 그리는 것은 윤곽선만으로 한정이다. PolyDraw라는 함수가 있다.

내부를 채우는 것은 한 함수로 지원되지 않으며, path라는 걸 써야 한다. 얘는 Windows GDI가 제공하는 강력한 벡터 그래픽 라이브러리로, 직선, 베지어 곡선, 원과 원호, 심지어 다른 트루타입 글꼴의 글립까지 몽땅 궤적으로 표현해서 한꺼번에 내부를 채울 수 있다. 구멍 처리도 물론 된다.
BeginPath (그리기) CloseFigure (그리기) EndPath 이런 식으로 말이다. 위의 1과 2를 모두 할 수 있다.

내 경험상 트루타입 폰트는 WINDING 방식으로 래스터라이징을 한다. 글꼴 글립을 그릴 때부터 제일 밖의 path는 시계 방향이고, 그 안의 구멍 윤곽을 기술하는 path는 반시계 방향이고, 구멍 안의 칠하는 영역은 또 시계 방향.. 이런 식으로 디자인을 해야 한다.

허나, 예전에 MS Office 2003 이하 버전에서 제공되던 클래식 WordArt는 이 원칙을 지키지 않고 트루타입 글꼴도 홀짝 ALTERNATE 방식으로.. 짝수 회 overlap 영역은 무조건 비웠던 것 같다.
그래서 composite glyph 형태로 표현되는 비완성형 한글 글꼴에서 글립이 겹칠 수 있는 복잡한 글자를 찍어 보면 저렇게 흰 부위 glitch가 발생하곤 했다. (아래 그림에서 ㅆ, ㅠ, ㅔ 부분 참고)

사용자 삽입 이미지

Office 2007 이상부터 제공되는 WordArt는 이 문제가 해결됐다. 그리고 아래아한글의 글맵시도 0여부 WINDING 방식으로 맞게 색칠을 하기 때문에 glitch가 발생하지 않는다.

그러고 보니.. MS Office는 지난 2007때부터 그래픽 엔진이 크게 바뀌었다. 워드아트의 글자 장식 기능도 리뉴얼 됐고 PowerPoint 같은 데서도 직통으로 사용 가능해졌는데, 정작 본가인 Word에서는 2003 이하의 클래식 워드아트가 제공됐다. 다음 버전인 Office 2010부터 Word에서도 동일하게 리뉴얼된 워드아트가 제공되기 시작했다.

Posted by 사무엘

2021/05/12 08:35 2021/05/12 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1885

1. 함수 명칭만으로 오버로딩 분간하기

C++에는 그 이름도 유명한 함수 오버로딩이라는 게 존재하기 때문에 어떤 함수를 이름만으로 유일하게 식별할 수 없다. 링크를 위한 심벌을 생성할 때는 자신이 받아들이는 인자들의 개수와 타입도 이름에다가 일일이 곁들여 줘야 동명이인(?) 함수들을 구분할 수 있다. 이 절차를 decoration이라고 한다.

그런데 바이너리 차원에서 함수 이름에 대한 구체적인 decoration 방식은 표준으로 정해진 것이 없어서 컴파일러마다 완전히 제각각이다. 이 때문에 동일한 타겟에다 동일한 포맷의 obj/lib(기껏해야 OMF 아니면 COFF..)라도 C++ 클래스 라이브러리는 컴파일러를 넘나드는 이식성이 크게 떨어진다.

C 단독이 거의 쓰이지 않는 오늘날까지도 유명 라이브러리들이 C언어 형태의 단순한 인터페이스를 고집하는 이유 중의 하나가 바로 이것이다. 아니면 그런 C 함수를 아주 얇게 감싸는(생성자, 소멸자, 오버로딩..) C++ wrapper 클래스를 만들더라도 하는 일이 정말 C 함수 호출밖에 없고, C++ 멤버 함수는 몽땅 인라이닝이 되게 만든다. 인라이닝이 됐다면 decoration이고 뭐고 걱정할 필요가 전혀 없어지기 때문이다.

그런데.. C++에서 함수를 (1) 실제로 호출하지 않고, (2) 저렇게 decoration 정보가 있는 link time도 아니면서 소스 코드 차원에서(= compile time) 오버로딩 동명이인 함수를 구분할 수는 없을까?

엥? (1)도 (2)도 아닌 애매한 상황은 C++에서 한동안 고려할 필요가 없었다. 그러나 2010년대부터 modern C++이 등장하고, 명칭만으로 type을 유추할 수 있는 auto와 decltype 키워드가 도입되면서 발생할 수 있게 됐다.

void foo() {}

라는 함수를 정의해 놓았다면

auto ptr = &foo;
decltype(&foo) ptr = ...;

이런 식으로 foo를 얼마든지 써먹을 수 있다. auto와 decltype이 자동으로 void (*)()이라는 타입을 유추해 내기 때문이다. 그런데 문제는..

void foo(int) {}

같은 overload를 더 만들었을 때이다.
그러면 이때부터 auto와 decltype에다가 foo를 언급할 수 없게 된다. 이 이름이 가리키는 함수의 prototype이 하나로 딱 떨어지지 않기 때문이다.

이에 대해 C++ 표준 스펙은 오버로딩된 함수 이름을 저기에다 집어넣는 것은 ill-formed라고만 지적하고 넘어간다. (☞ 링크)
이건 컴파일된 obj가 아니라 소스 코드 레벨이기 때문에 decorate된 저수준 명칭을 사용할 수도 없다. 어느 오버로드인지를 지목하는 문법은 딱히 마련하지 않고 넘어간 듯하다.

글쎄, 생각 같아서는 foo as_in () 내지 foo as_in (int) 같은 오버로딩 식별을 위한 토큰이 필요해 보인다.
파이썬이라면 모를까 C++에서 겨우 이런 용도를 위해 영단어 예약어를 도입하는 건 보기 좋지 않다. 그냥 foo 다음에 -> : []처럼.. 데이터 포인터가 아닌 함수 포인터라면 쓰일 일이 없는 기존 연산자 토큰을 늘어놓아서 이 함수의 인자들의 타입을 기술해 주면 될 것이다.

참고로, 함수의 포인터를 얻는 상황에서는 static_cast가 아주 유용하게 쓰인다.
void *p = static_cast<int (*)(int,int)>( func_ptr );

이렇게 해 주면 이 형변환은 func_ptr이 int 2개를 받고 int를 리턴하는 놈이 실제로 선언이나 정의돼 있을 때에만 성공한다. 그런 게 없으면 에러.. 그러니 안전하다. 흥미롭지 않은가? 형변환 연산자가 함수의 동명 오버로드를 분간할 때도 쓰인다니 말이다.
C-style cast나 reinterpret_cast는 그런 면모가 없고 무조건 될 것이다.

2. 복합 포인터 간의 더 세밀한 형변환 연산자

C/C++에서 void*라는 포인터 타입은 잘 알다시피 다른 아무 자료형을 가리키는 포인터를 받아서 대입될 수 있다. 공집합은 다른 모든 집합들의 부분집합이며, 1은 다른 모든 자연수의 약수인 것과 같은 이치이다.
malloc을 비롯해 메모리를 할당하는 함수들은 특정 자료형에 구애받지 않는 void*를 되돌린다. 그런데 이 포인터값을 함수의 리턴값이 아니라 함수 인자로 전달된 포인터가 가리키는 주소에다 받게 하려면 어떡해야 할까?

그럴 때는 별 수 없이 이중 포인터가 쓰이게 된다. 자주 보는 물건은 아니지만 Windows에서 COM 객체를 다루다 보면 QueryInterface 메소드, CoCreateInstance 함수 때문에 접하게 된다. COM에서는 함수 리턴값은 에러코드(정수값)를 되돌리고, 포인터는 인자로 전달된 주소를 통해 받기 때문이다.

여기서 우리는 문법 차원에서 약간의 어색함을 경험하게 된다.
void*는 int*, char* 등 다른 포인터 타입과 호환되지만 이중 포인터끼리는 그렇지 않다. 가령, void**와 int**, char** 따위는 호환되지 않는다.
상속 관계도 마찬가지이다. IUnknown**에다가 IUnknown의 파생 클래스의 포인터의 주소를 바로 넘겨줄 수는 없다.

단순 포인터는 한쪽은 호환되고, 역변환은 static_cast만 해 주면 된다. 그러나 이중 이상의 포인터는 가리키는 타입도 타입 그 자체가 아니라 그 타입의 포인터이다. 그렇다 보니 타입간의 아무런 계층 관계가 인정되지 않는다. 어느 방향으로든 얄짤없이 무식하게 reinterpret_cast만 써야 한다.

글쎄, 이런 일이 자주 발생하고 구분해 줄 필요가 꼭 있다면 pointer_cast 내지 reference_cast라고 해서.. 다중 포인터라도 참조 깊이가 동일하고 최종적으로 도달하는 타입이 서로 static_cast급으로 호환된다면 변환을 허용하는 형변환 연산자를 둘 법도 해 보인다. 저건 굳이 reinterpret_cast까지 사용해야 할 정도로 과격하고 위험해 보이지는 않기 때문이다.

하지만 저 상황만을 위해서 별도의 키워드까지 추가하는 건 좀 overkill 낭비로 보이니 그렇게 되지 않은 것 같다. 배열이야 필요에 따라 3차원 이상의 다차원 배열이 쓰일 수 있지만, 포인터는 본인도 20여 년에 달하는 프로그래밍 인생 이래로 3중 이상 깊이의 포인터를 사용할 일은 전혀 없었다. 즉, void ***p라든가, 이중 포인터에 대한 주소값 같은 것 말이다.

포인터라는 게 크게.. (1) 타 자료형에 대한 포인터, (2) 함수에 대한 포인터, (3) 구조체 및 클래스의 멤버에 대한 포인터라는 세 갈래로 나뉘는 것 같다. 물론, (1)~(3) 종류 불문하고 포인터를 가리키는 포인터는 자동으로 (1)에 속하게 된다. (2)와 (3)은 void*로 싸잡아 일컫는 것이 권장되지 않거나 원천적으로 가능하지 않다.

멤버 포인터의 포인터 내지 배열 같은 것은.. 잠깐 테스트를 해 보니 그래도 문법적인 한계나 typedef 땜빵 없이 이렇게 바로 선언해서 쓸 수 있긴 하다. 물론 멤버 포인터 자체도  쓰일 일이 극도로 드문데 하물며 그걸 또 가리키는 포인터는.. 삼중 이상의 포인터만큼이나 정말 레어템이다. 아래처럼 말이다.

class A {
public:
    void func(int x);
};

void (A::*pfn)(int) = &A::func;
void (A::**ppfn)(int) = &pfn;
void (A::*apfn[1])(int) = { &A::func };

A obj;
(obj.*pfn)(1);
(obj.**ppfn)(2);
(obj.*apfn[0])(3);

복잡한 포인터에서 세부 속성 간의 형변환은 비단 다중 포인터에만 존재하는 게 아니다. 함수 포인터에다가 인자의 개수와 calling convention 같은 건 완전히 일치하고, 일부 인자나 리턴값만이 void*와 char*처럼 미묘하게 다른 함수를 집어넣는 상황을 생각해 보자. 일반 함수뿐만 아니라 C++ 멤버 함수의 포인터도 해당된다.

이때도 지금 문법에서는 닥치고 C-style 또는 reinterpret_cast를 쓰는 수밖에 없다. 하지만 static_cast와 reinter_*의 중간 완충 역할을 해서 저럴 때만 유도리를 허용하는 연산자가 있다면 문법 차원에서 실수가 더 줄어들 수 있고 더 깔끔한 코드를 작성할 수 있다. 프로그래밍 언어에서 type theory의 심오함이 문득 느껴진다.

3. 복수 인터페이스의 구현체에서 IUnknown 베이스 얻기

C++로 COM 인터페이스를 지원하는 클래스를 구현하다 보면.. 다중 상속 기능을 이용하여 한 클래스에다가 2개 이상의 인터페이스를 한꺼번에 집어넣는 경우가 있다.
예를 들어 한 윈도우가 drag & drop을 양방향으로 지원해서 데이터를 밖으로 날릴 수도 있고 받을 수도 있다면, 그 윈도우를 나타내는 클래스(가령, CMyWnd)에다가 IDataObject, IDropSource와 IDropTarget를 몽땅 때려박는 게 편하다.

이렇게 하고 나면, 그 CMyWnd를 상대로 인터페이스들의 베이스인 IUnknown의 QueryInterface, AddRef, Release 메소드를 호출하는 것이야 아무 문제 없이 된다.
다중 상속의 특성상, CMyWnd를 IDataObject, IDropSource, IDropTarget 등으로 캐스트한 포인터 값은 제각각 달라질 수 있다. 왜냐하면 멤버 변수가 없는 인터페이스이더라도 상속을 하나 할 때마다 vtable 포인터의 크기 하나씩은 클래스에다가 차지하게 되기 때문이다.

하지만 이들의 vtable에서 IUnknown 파트는 모두 공통으로 CMyWnd가 구현한 동일한 QueryInterface, AddRef, Release 함수를 가리키게 된다. 이게 바로 마법의 비결이다.
단, 둘째, 셋째, n째 인터페이스들은 this 포인터 값을 살짝 보정한 뒤에 원래 함수를 호출하는 thunk가 추가된다. 마법이 공짜는 아닌 셈이다. 그래서 다중 상속에서는 내가 함수를 호출한 객체의 주소와, 해당 멤버 함수가 받은 this 포인터의 값이 일치하지 않을 수 있다.

그렇게 다중 상속에서 함수 호출과 this 보정 문제가 해결되었는데.. 정작 CMyWnd 오브젝트의 포인터를 IUnknown* 자체로 cast 하는 것은..??? 뜻밖에도 되지 않고 컴파일 에러가 난다. 암시적 자동 형변환은 물론이고, static_cast와 C-style cast도 통하지 않는다.
왜냐하면 얘는 2개 이상 여러 인터페이스를 구현했는데 어느 놈을 기준으로 삼아서 IUnknown으로 cast 해야 할지 알 수 없기 때문이다. 모호성이 존재한다는 것이다. 뜨악~

현실에서 어지간해서는 이런 일을 겪을 일이 거의 없다. 그냥 파생 클래스 구현체가 베이스 인터페이스의 완벽한 상위 호환이니, 어지간한 상황에서는 그냥 그 클래스를 쓰면 되지 굳이 베이스로 형변환을 할 일 자체가 없기 때문이다.

하지만 아무 인터페이스 오브젝트나 받아들여서 레퍼런스 카운트 관리만 한답시고 IUnknown을 인자로 받는 함수가 드물게 있을 수 있다. 그 함수에다가 이런 오브젝트를 덥석 넘겨주면 어느 베이스의 IUnknown을 골라야 할지 모르겠다는 태클에 걸린다. 저 인터페이스들이 IUnknown을 가상(virtual) 상속을 한 게 아니기 때문에 이 문제를 피해 갈 수 없다. 어차피 인터페이스에는 데이터 멤버도 없으니 아무거나 골라도 됨에도 불구하고 말이다.

그 클래스가 상속한 베이스들 중 가상 함수가 존재하는 제일 첫 놈이 IUnknown 기반의 인터페이스라면.. 그 클래스의 인스턴스의 포인터는 그대로 직통으로 IUnknown으로 형변환해도 된다. 하지만 이건 이게 C++의 문법 차원에서 안전이 보장될 수 없는 동작이기 때문에 컴파일 에러가 발생하는 것이다.

C-style cast는 static_*과 reinterpret_*의 중간 정도 위상을 차지하는 물건이며, 포인터 간의 형변환에서는 오히려 후자에 더 가까운 위치에 있다. 하지만 다중 상속에 대해서는 의외로 선 넘지 않는 안전 장치가 걸려 있는가 보다.
저 때 자기 자신을 베이스인 IUnknown으로 강제로 둔갑시키는 수단은 reinterpret_cast밖에 없다.
아니면!! 자신을 void*로 먼저 전환한 뒤에 그걸 IUnknown으로.. static_cast를 두 번 적용하면 된다. 물론 이것들은 다 at your own risk를 감수하고 해야 한다.

이 문제를 해결한답시고 CMyWnd에 대해서 무식하게 QueryInteface(IID_IUnknown, &obj)를 하는 건 너무 오버 같다.
뭐, 어느 베이스를 선택할지 static_cast<IDataObject*>(&obj) 이렇게 명시적으로 지정을 해 주면 모호성이 해소되어 C++ 차원에서 IUnknown으로 cast도 가능해진다.
하지만 이것도 언어 차원에서 더 깔끔하게 해결할 방법이 없는지, const 객체뿐만 아니라 베이스 인터페이스에 대해서도 select_any 같은 속성을 지정해 줄 수는 없을지 궁금하다.

참고로 이런 형변환이 일어나는 곳은 해당 객체 자체가 아니라 십중팔구 그 객체의 포인터들이다. 그러니 그 클래스에서 operator IUnknown*() { return static_cast<****>(this); } 이렇게 전용 형변환 연산자를 구비하는 것은.. 성능 오버헤드는 없지만 언어 문법 차원에서의 아주 깔끔한 해결책으로 보기는 어려워 보인다.;;;

Posted by 사무엘

2021/04/30 08:33 2021/04/30 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1882

1. make, build

요즘 소프트웨어라는 건 여러 개의 실행 파일들로 구성되고, 그 각각의 실행 파일들도 수십~수백 개에 달하는 소스 코드들로 구성된다. 이를 빌드하려면 단순 배치 파일이나 스크립트 수준으로는 감당하기 어려울 정도로 많은 옵션과 입력 파일 리스트들을 컴파일러 및 링커에다가 일일이 전해 줘야 한다. 기존 소스 코드들을 빌드하는 시나리오를 짜는 것조차도 일종의 프로그래밍처럼 된다.

그래서 이런 빌드 시나리오를 기술하는 파일을 makefile이라고 하며, 이 시나리오대로 컴파일러와 링커를 호출해서 빌드를 수행해 주는 별도의 유틸리티가 make라는 이름으로 따로 존재한다. 얘는 이전 빌드 때 만들어져 있는 obj 파일과 소스 파일과의 날짜를 비교해서 새로 바뀐 파일만 다시 컴파일 하는 정도의 지능도 갖추고 있다.
그리고 이름이 저렇게 고정 불변이며, 한 디렉터리에 하나씩만 존재하는 것으로 여겨진다. 프로젝트는 디렉터리별로 독립적이므로..

그런데 소스 말고 헤더 파일은? 조금 어렵다. 이게 수정되면 역으로 얘를 인클루드 하는 소스 파일들도 재컴파일이 돼야 하는데, make 유틸이 C/C++ 컴파일러나 전처리기는 아닌지라, 그걸 자동으로 파악하지는 못한다. 이건 makefile 스크립트 내부에서 각 소스별 헤더 파일 의존성을 사람이 수동으로 지정해 줘야 한다. 이를 기술하는 문법이 따로 있다.
이건 매번 풀 빌드 명령을 내리는 것보다 분명 편리하지만 그래도 사람이 의존성을 잘못 지정할 경우 빌드가 꼬일 수 있는 잠재적 위험 요인이다.

이렇듯 C/C++ 공부 좀 해서 본격적인 프로그램을 개발하거나 기존 제품을 유지 보수하려면, 언어 자체 말고도 다른 툴이나 스크립트를 알아야 할 것이 이것저것 생긴다. 이 바닥도 체계가 정말 복잡하기 때문에, 잘 모르는 사람은 말 그대로 소스까지 다 차려 놓은 오픈소스 프로젝트를 멀쩡히 받아 놓고도 빌드를 못 해서 돌려보지 못하곤 한다. 최소한 Visual C++ 솔루션 파일 하나 달랑 열어 놓고 F7만 누르면 바로 짠~ 빌드 되는 물건은 아니기 때문이다.

물론 그런 복잡한 시스템들은 훨씬 더 복잡한 상황을 간편하게 제어하고 관리하고 프로세스를 자동화하기 위해 도입되었겠지만.. 그마저도 초보 입문자에게는 쉬운 개념이 아니다.
Visual Studio 같은 개발툴들이 그런 make 절차를 얼마나 단순화시키고 프로그램 개발을 수월하게 만들어 줬는지 짐작이 된다. 당장 include 의존성을 자동으로 파악하는 것만 해도 말이다.

이런 개발툴 덕분에 프로그래머가 makefile 스크립트를 일일이 건드려야 할 일이 없어졌다. makefile은 해당 개발툴이 읽고 쓰는 프로젝트 파일로 대체됐으며, 얘는 비록 텍스트 포맷이긴 하지만 사람이 수동으로 편집해야 할 일은 거의 없다. 한때는 포맷이 제각각이었는데 요즘은 xcode건 비주얼이건.. 껍데기는 XML 형태인 것이 대세가 됐다. 스크립트라기보다는 설정 데이터 파일에 더 가까워진 셈이다.

Visual C++도 지금 같은 번듯한 IDE가 갖춰진 버전은 적어도 1995년의 4.0이다. 그때의 IDE 이름은 Developer Studio이었다. 이 시절에는 얘도 IDE와 별개로 유닉스 유틸과 비슷한 스타일의 make를 따로 갖추고 있었으며, 프로젝트 파일로부터 make 스크립트를 export해 주는 기능도 갖추고 있었다. 그러나 그 기능은 후대의 버전에서 곧 없어졌다. 명령 프롬프트로 빌드를 하는 건 그냥 IDE 실행 파일의 기능으로 흡수되었다.

2. cmake

유명한 대규모 크로스 플랫폼 오픈소스 프로젝트를 받아 보면 분명 Windows를 지원하고 Visual C++로 빌드도 가능하다고 명시돼 있는데, 그 빌드라는 게 내가 생각하고 이해 가능한 방식으로 행해지는 건 아닌 경우가 있다.
한때 직장에서 이미지 처리와 인식 때문에 OpenCV며 Tesseract며 머신러닝 라이브러리까지 C/C++에서 돌리겠답시고 삽질을 좀 한 적이 있었는데.. 이때 이런 식으로 지금까지 듣도 보도 못했던 프로젝트 구조와 빌드 방식 때문에 식겁을 하곤 했다.

압축을 풀거나 git으로 생성된 저장소를 아무리 들여다봐도 sln, vcxproj 같은 파일은 보이지 않는다. 먼저 MinGW에다 cmake 같은 유닉스 냄새가 풍기는 런타임을 설치해야 한다. 그래서 cmake를 돌리고 나면 자기 혼자 무슨 라이브러리 같은 걸 한참을 받더니 그제서야 디렉터리 한구석에 Visual C++용 솔루션과 프로젝트 파일이 생긴다.

소스를 사용자 자리에서 일일이 빌드해서 쓰는 것도 모자라서 빌드 스크립트 자체도 사용자 자리에서 즉석에서 동적 생성되는 모양이다. 흠..;
그 생성된 솔루션 파일을 Visual C++에서 열어서 빌드를 해 보면.. 비록 컴파일러는 마소 것을 쓰더라도 소스 파일이 선택되고 빌드되는 방식은 절대로 Visual C++ IDE의 통상적인 스타일대로 진행되는 게 아니다.

솔루션/클래스 view에는 아무것도 뜨는 게 없으며, 빌드되는 파일을 열어도 인텔리센스 따위 나오는 게 없다. 이 상태로 Visual C++ IDE에서 곧장 코드를 읽으면서 편집할 수 있지 않다. IDE에서는 그냥 debug/release나 win32/x64 같은 configuration을 변경하고 빌드 명령만 내릴 수 있을 뿐이다.

이런 프로젝트는 Visual Studio도 반드시 거기서 쓰라고 하는 버전만 써야 한다. 가령, 2017을 쓰라고 했으면 IDE까지 꼭 2017을 깔아야 한다. 2019에다가 컴파일러 툴킷만 2017을 설치하는 식으로는 안 통한다. 도대체 프로젝트를 어떻게 꾸며야 이런 빌드 환경이 만들어지는지 나로서는 알 길이 없다.

알고 보니 얘는 프로젝트의 Configuration type이 Utility 내지 Makefile로 잡혀 있었다. Visual C++에서 빌드되는 일반적인 프로젝트라면 저건 EXE, DLL, static library 중 하나로 지정하는 속성인데, 그런 것으로 지정돼 있지 않다.

그렇기 때문에 이 프로젝트에서 Visual Studio IDE는 그냥 명령줄을 실행해 주는 셔틀 역할밖에 안 한다. Visual C++ 컴파일러가 호출되는 것도 IDE가 원래 동작하는 방식으로 호출되는 게 아니다. 세상에 C/C++ 프로젝트를 이런 식으로 만들 수도 있다는 것을 어렴풋이 경험하게 됐다.

요컨대 cmake는 기존 make 툴의 또 상위 계층이며, 얘만으로도 기능이 굉장히 많고 덩치가 큰 프로그램이다. qt가 소스 레벨 차원에서 Windows와 리눅스와 맥을 모두 지원하는 범용 GUI 프레임워크로 유명하다면, cmake는 범용 빌드 시스템 관리자인 셈이다. qt를 기반으로 개발되는 GUI 앱의 프로젝트를 cmake 기반으로 만들면 진짜로 한 소스와 한 프로젝트로 Visual C++과 xcode와.. 음 리눅스용 IDE는 뭔지 모르겠지만 아무튼 진정한 크로스플랫폼 프로그램을 개발하고 관리할 수 있을 것으로 보인다.

맥OS야 요즘은 다 유닉스 스타일의 터미널을 갖추고 있으니 빌드 내지 패키지 관리 툴이 Windows보다는 이질감이 덜하다. 그러나 맥도 리눅스와 완전히 동일하게 호환되는 건 아니라는 건 감안할 필요가 있다.
그나저나 같은 x64 환경이면 GUI 말고 a.out급의 명령 프롬프트 실행 파일은 리눅스와 맥이 바이너리 차원에서 호환되나?? 아마 그렇지는 않지 싶다.

3. Source Insight

Source Insight라고 프로그래밍 및 소프트웨어 개발로 먹고 사는 사람이라면 다들 알 만한 유명한 개발툴이 있다. 단순 텍스트 에디터보다는 코드 구조 분석과 심벌 조회 기능이 훨씬 더 정교하게 갖춰져 있지만, 그렇다고 Visual Studio 같은 급으로 특정 플랫폼용 컴파일러나 디버거와 밀접하게 연결돼 있는 IDE도 아니다. 위상이 둘의 중간쯤에 속하는 독특한 물건이다.

즉, Source Insight는 각종 언어들 컴파일러의 ‘프런트 엔드’ 계층에만 특화돼 있다.
얘가 굉장히 독특한 점이 뭐냐 하면.. 전문 IDE와 달리, 실제 컴파일 결과에 꼭 연연하지 않고 유도리가 있다는 점이다. 그래서 코드에 컴파일 에러가 좀 있더라도 괜찮고, 심지어 #if #else로 갈라지는 부분까지 개의치 않고 특정 심벌이 정의된 부분을 몽땅 한꺼번에 조회 가능하다.

그래서 프로젝트와 configuration이라는 걸 꼭 바이너리를 빌드하는 단위로 만들 필요 없이, 전적으로 사용자가 심벌을 조회하고 코드를 분석하고 싶은 큼직한 단위로 만들 수 있다. 생각해 보니 이게 Source Insight의 강점이다.
Visual Studio나 Android Studio 같은 IDE만 쓰면 되지 이런 게 왜 필요하냐고..?? 응, 필요하고 유용하더라. 틈새시장을 잘 공략한 제품 같다.

그나저나 최근에 회사 업무 때문에 SI 3.5 버전을 쓸 일이 있었는데.. 본인은 또 한 번 굉장히 놀랐다.
2019년 11월에 릴리스 됐다는 프로그램이 알고 보니 구닥다리 노인학대의 종결자인 무려 Visual C++ 6으로 빌드돼 있었기 때문이다.;; ㅠㅠㅠㅠ 실행 파일 헤더에 기록돼 있는 링커 버전, 섹션간의 4KB 단위 패딩(옛날 스타일), 생성돼 있는 기계어 코드의 패턴으로 볼 때 확실하다.

게다가 유니코드 기반도 아니었다. 도움말을 보니 여전히 Windows 9x를 지원한다고 쓰여 있다. 요즘 같은 시대에 레거시 OS 종결자인 프로그램이 날개셋 말고 더 있었구나;;
회사에서만 쓰는 프로그램이어서 많이 다뤄 보지는 못했지만 쟤들도 자기 제품에다가 분명 최신 C++1x 문법을 구현했을 텐데, 그걸 자기들이 제품 코딩을 할 때 좀 써 보고 싶은 생각은 하지 않았을까..?? 피치 못할 사정이 있어서 VC6을 그렇게 오랫동안 써 온 건지 궁금하다.

그나마 2020년에 출시된 SI 4.0에서는 유니코드를 지원하고 많은 변화가 있었다고 한다. 거기서는 자기네 개발툴도 새 버전으로 갈아타지 않았겠나 추측해 본다.

4. Visual C++

그리고 나의 사랑하는 툴인 Visual Studio.. 얘는 2019 이후로 202x이 나오려나 모르겠다. 지난 2년 동안 꾸준히 소규모 업데이트 형태로만 버전업을 거듭한 끝에, 무려 16.9.x 버전에 진입했다.
업데이트가 너무 잦아서 좀 귀찮은 감이 있긴 했지만, IDE 자체의 안정성은 야금야금 눈에 띄게 강화되어 왔다. 그 예를 들면 다음과 같다.

  • 예전에는 컴에 절전/최대 절전을 반복하다 보면 IDE의 글꼴이 내가 변경하기 전의 것으로 되돌아가곤 했는데 그 오동작이 어느 샌가 발생하지 않게 됐다. 상당히 성가신 버그였다.
  • 가끔 대화상자 리소스 편집기를 열 때 IDE가 응답이 멎던 현상이 이제 더는 발생하지 않는다.
  • 또 가끔은 프로젝트 대렉터리 내부에 RCxxxx, *.vc.db-??? 등 임시 쓰레기 파일이 프로젝트를 정상적으로 닫은 뒤에도 지워지지 않고 남아 있었던 것 같은데.. 이제는 그런 문제가 확실히 해결됐다.

예전에도 언급한 적이 있지 싶은데, 난 Visual Studio IDE가 서로 다른 프로세스 인스턴스끼리도 연계가 더 자연스럽게 됐으면 좋겠다.

  • 다른 인스턴스에서 이미 열어 놓은 솔루션을 또 열려고 시도한다면 그냥 그 인스턴스로 이동하기
  • 다른 인스턴스에서 만들어 놓은 문서창끼리도 한 탭으로 묶거나 떼어내기 지원 (크롬 브라우저처럼)

그리고...

  • BOM이 없는 파일의 인코딩, 또는 새 파일을 첫 저장할 때의 기본 인코딩을 utf-8로 인식해 줬으면 좋겠다.
  • 탭이 설정된 대로뿐만 아니라, 주변 파일의 모양을 보고 탭인지 공백 네 칸인지 얼추 분위기를 파악해서 동작하는 기능이 있으면 좋겠다.
  • 프로젝트별로 소스 파일 곳곳에 지정된 책갈피와 breakpoints들의 세트들을 여럿 한꺼번에 저장하고 불러오는 기능이 있으면 좋겠다. 디버그를 위해 실행할 프로그램과 인자도 여러 개 한꺼번에 관리하고 말이다.

끝으로.. Visual C++은 2015부터가 Windows 10과 타임라인을 공유한다. 이때 CRT 라이브러리의 구성 형태가 크게 바뀌었다. vcruntime이 어떻고 ucrtbase가 어떻고.. 그리고 Visual Studio 2015~2019는 재배포 패키지도 한데 통합됐다.

그래서 그런지 요즘은 Visual C++이 설치되어 있지 않아도 시스템 디렉터리를 가 보면 msvcp140, mfc140 같은 DLL은 이미 들어있다.
20여 년 전의 msvcrt와 mfc42 이래로 운영체제의 기본 제공 DLL과 Visual C++의 런타임 DLL이 일치하는 나날이 찾아온 건지 모르겠다.

Posted by 사무엘

2021/04/03 08:34 2021/04/03 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1872

1. 아이콘 불러오기

창(그 자체 또는 클래스)에다가 아이콘을 지정하기 위해 흔히 LoadIcon 함수가 쓰인다.
얘는 원래 고정된 32*32 크기의 기본 아이콘 하나만을 달랑 가져오는 함수로 출발했다. 허나 Windows 95부터는 글자 크기와 같은 16*16 작은 아이콘이라는 것도 추가됐고, 나중에 XP쯤부터는 24*24, 48*48 같은 다양한 중간 크기가 도입됐다.

거기에다 화면 DPI까지 가변화가 가능하지, 256픽셀 대형 아이콘까지 도입됐지.. 이거 뭐 아이콘이라는 건 이제 도저히 단일 크기 이미지라고 볼 수 없는 물건으로 바뀌었다. 한 아이콘이 다양한 크기와 색상 버전을 가질 수 있다는 점에서 과거의 비트맵 글꼴과 약간 비슷한 위상이 됐다.

한편, 원래 마우스 포인터(cursor)와 아이콘은 기술적인 원천과 본질이 거의 같은 물건이었다. 작은 정사각형 크기의 이미지 비트맵과 마스크 비트맵의 쌍으로 표현된다는 점에서 말이다. 마우스 포인터는 거기에다가 hot spot 위치 정보가 추가됐을 뿐이었다.
그랬는데 마우스 포인터는 애니메이션이라는 바리에이션이 생겼고, 아이콘은 크기 바리에이션이 생겼다고 보면 되겠다. 동일한 특성을 같이 공유하다가 서로 다른 방향으로 기능이 추가된 것이다.

Windows 95에서는 창이나 창 클래스에다가 아이콘을 지정할 때 큰 아이콘과 작은 아이콘을 구분해서 지정할 수 있게 했다. 그래서 WNDCLASS에는 멤버가 하나 더 추가된 Ex버전이 만들어졌다. WM_SETICON 메시지도 아이콘의 대소 종류를 지정하는 부분이 wParam에 추가됐다.

그리고 LoadIcon 함수 자체도.. Ex가 추가된 건 아니고, 비트맵, 아이콘, 포인터까지 다양한 크기를 모두 처리할 수 있는 완벽한 상위 호환 LoadImage에 흡수되었다. 스펙을 보면 알겠지만 기능이 정말 많다.

하지만 내 경험상, 굳이 Ex 버전을 쓰지 않고 WNDCLASS의 hIcon에다가 큰 아이콘만 LoadIcon으로 지정해 주더라도.. 동일한 ID의 아이콘에 큰 아이콘과 작은 아이콘이 모두 있다면 별도의 처리가 없어도 괜찮았다. 프로그램 타이틀 창에 작은 아이콘은 그 별도의 작은 아이콘으로 자동으로 지정되는 듯하다. 큰 아이콘을 흐리멍텅하게 resize한 놈이 지정되는 게 아니라는 뜻이다.

그래서 본인은 지금까지 프로그램을 개발하면서 굳이 WNDCLASSEX와 RegisterClassEx를 사용한 적이 없었다. 큰 아이콘과 작은 아이콘이 ID까지 다른 서로 완전히 다른 아이콘일 때에나 이런 전용 함수가 필요한 듯하다.
단, 윈도우 클래스를 등록하는 상황이 아니라 대화상자 같은 데서 WM_SETICON으로 아이콘을 지정할 때는 큰 아이콘과 작은 아이콘을 LoadImage 함수로 구분해서 일일이 지정해 줘야 했다.

참고로 Windows에서 아이콘이라는 건 메모리 관리 형태가 크게 세 종류로 나뉜다. (1) 메시지박스에서 흔히 볼 수 있는 ! ? i 표지처럼 시스템 공통 공유 아이콘, (2) 응용 프로그램의 아이콘 리소스를 직통으로 가리키기만 하는 공유 아이콘, (3) 그게 아니라 자체 메모리를 할당하여 동적으로 독자적으로 생성된 놈.

(3)만이 나중에 DestroyIcon을 호출해서 제거해 줘야 한다. (2)는 해당 모듈의 생존 주기와 동일하게 관리된다. (1)이야 뭐 언제 어디서나 유비쿼터스이고..
그리고 RegisterClass 계열 함수가 특례를 보장해 주는 건 역시 리소스 기반인 (2) 한정이다.
wndClass.hIcon = LoadIcon(hInst, IDI_MYICON) 이렇게 돼 있던 곳에서 LoadIcon(...)의 결과를 CopyIcon( LoadIcon(...))으로 감싸서 아이콘의 형태를 (3)으로 바꿔 보시라. 그러면 그 프로그램의 제목 표시줄에 표시된 작은 아이콘은 큰 아이콘을 resize한 뭉개진 모양으로 곧장 바뀔 것이다. 이것이 차이점이다.

사실, Visual Studio의 리소스 에디터 상으로는 구분이 잘 되지 않지만, 응용 프로그램 모듈(EXE/DLL)에 저장되는 리소스 차원에서는 단순 아이콘(RT_ICON)과 아이콘 집합(RT_GROUP_ICON)이 서로 구분되어 있다. 후자는 전자의 상위 호환이다. RegisterClass는 이를 감안해서 동작하지만 HICON 자료형이나 LoadIcon 같은 타 함수들은 일반적으로 그렇지 않은 것으로 보인다.

이럴 거면 wndClass.hbrBackground에 (HBRUSH)(COLOR_WINDOW+1)이 있는 것처럼 hIcon에도 (HICON)IDI_MYICON 이런 게 허용되는 게 더 깔끔하겠다는 생각도 든다.

자, 이 정도면 아이콘 지정에 대해서 더 다룰 게 없어야 하겠지만.. 그렇지 않다. LoadImage 함수에 약간의 버그가 있다.
얘는 (1) 시스템 공용 아이콘에 대해서는 요청한 크기에 맞는 버전을 되돌리지 않고 가장 큰 놈 또는, 걔네들 용어로는 캐시에 보관돼 있는 크기의 이미지만을 되돌린다. 즉, 기존 LoadIcon과 다를 바 없이 동작한다.

특정 크기에 해당하는 아이콘을 정확하게 되돌리라고 별도의 함수까지 만들었는데 그건 (2), (3) 계층에 해당하는 custom 아이콘에 대해서만 동작한다. (1)에 대해서는 글쎄, 성능 때문인지 호환성 때문인지 잘못된 동작을 일부러 방치해 버리고는 더 고치지 않는 듯하다.

그렇기 때문에 시스템 공용 아이콘의 16픽셀급 작은 버전을 이 함수로 얻을 수 없다.
Windows Vista부터는 사용자 계정 컨트롤이라는 보안 기능이 추가되어서 관리자 권한을 나타내는 방패 아이콘(IDI_SHIELD)이 추가되었다. 얘도 UI 텍스트와 함께 작은 크기로 그려야 할 텐데.. LoadImage로는 256픽셀짜리 대형 아이콘만 얻을 수 있기 때문에 이걸 16픽셀로 줄여서 그리면 보기가 흉하다.

마소에서는 LoadImage 함수의 버그를 고친 게 아니라 Vista부터 LoadIconMetric이라는 함수를 추가했다.
얘를 사용하면 시스템 공용 아이콘에 대해서도 정확한 크기를 얻을 수 있다.
얘는 아이콘을 언제나 (3)번 형태로 동적 할당해서 되돌리기 때문에 다 사용하고 나서는 DestroyIcon을 해 줘야 한다. 처리하기 간편한 shared, read-only 속성을 포기하고 정확한 동작을 하도록 로직을 바꾼 것 같다.

그 외에 SHGetStockIconInfo라는 함수도 있어서 얘를 사용하면 한 마디로 탐색기에서 쓰이는 각종 디스크 드라이브, 폴더, 돋보기, 네트워크 등의 표준 셸 아이콘을 얻을 수 있다.

2. DrawFocusRect

Windows에서 대화상자를 키보드로 조작하다 보면, 현재 포커스를 받아 있는 각종 버튼(라디오/체크 박스 포함)이라든가 리스트 아이템에 가느다란 점선 테두리가 쳐진 것을 볼 수 있다. 이것은 DrawFocusRect라는 함수를 이용해서 그려진 것이다.

마소에서는 키보드 포커스를 받아 있는 GUI 구성요소에다가는 요 함수를 호출해서 점선으로 테두리를 그려 줄 것을 GUI 디자인 표준으로 명시하고 있다. 뭐, 일반 프로그래머라면 버튼 같은 커스텀 컨트롤을 직접 구현하거나 owner-draw 리스트박스를 만들 때에나 숙지할 만한 개념이다. 다른 요소들을 다 그리고 나서 맨 마지막으로 focus 테두리를 그려 주면 된다.
다만, 에디트 컨트롤은 애초에 깜빡이는 캐럿(caret; cursor)이 포커스에 대한 시각 피드백 역할을 하고 있기 때문에 또 점선 테두리를 그려 줄 필요가 없다.

이 점선은 이미 아시겠지만 xor 연산을 가미한 반전색이다. 원래 색과 반전 색이 교대로 등장하는 아주 단순한 패턴이다.
요즘 세상에 테두리는 그냥 알파 채널을 가미한 옅은 실선으로 그려도 될 것 같지만, 이 분야는 구닥다리 GDI 레거시 API와의 호환 문제도 있어서 그런지 여전히 옛날 그래픽 패러다임이 쓰이고 있다. 이 xor 테두리는 계산량 적고 간편할 뿐만 아니라, 다시 한번 그리라는 명령을 내리면 싹 사라지고 원래 이미지로 돌아온다는 특성도 있어서 더욱 편리하다.

이 테두리는 두께가 오랫동안 1픽셀로 고정되어 있었다. 하지만 1픽셀만으로는 너무 가늘어서 눈에 잘 띄지 않고 시각 장애인의 접근성에 좋지 않다는 의견이 제기되었다. 게다가 모니터의 해상도가 갈수록 올라가고 100%보다 더 높은 확대 배율도 등장하다 보니, 1픽셀 고정 두께의 한계는 더욱 두드러지게 됐다.

이 때문에 Windows XP부터는 제어판 설정에 따라 2픽셀 이상의 focus 테두리도 등장할 수 있게 됐다.
이 조치가 응용 프로그램에서 특별히 문제가 될 일은 거의 없겠지만, DrawFocusRect로 평범한 직사각형을 안 그리고 1~2픽셀 남짓한 두께의 수직선· 수평선을 그려 왔다면 선이 의도했던 대로 그려지지 않을 수도 있다. 같은 영역에 선이 두 번 그려지면서 점선이 없어져 버리기 때문이다.

DrawFocusRect는 기술적으로 사각형 테두리 모양으로 50% 흑백 음영 비트맵을 브러시로 만들어서 PatBlt() 한 것과 완전히 동일하다. raster operation은 PATINVERT (흑백 xor target)이고 말이다. 그러면 원래색 / 반전색이 교대로 등장한다.
xor이 아니라 and라면 과거 Windows 9x/2000의 시스템 종료 대화상자의 배경처럼 "검정 / 원래색"이 교대로 등장하면서 화면이 반쯤 어두워지는 걸 연출할 수 있을 텐데.. 이 래스터 연산 코드는 따로 정의돼 있지 않은 것 같다.

그런데.. Windows의 GDI API에서 흑백 비트맵은 자체적인 색이나 팔레트 따위가 없으며, 현재 DC의 글자색과 배경색이 DC에 select된 비트맵의 색깔로 쓰인다.
그렇기 때문에 DrawFocusRect로 정확하게 반전 점선 테두리를 그리려면 호출 당시에 해당 DC의 글자색과 배경색을 반드시 black & white로 해 줘야 한다. 시스템 색상 따질 것 없이 RGB(0,0,0)과 RGB(255,255,255)로 하드코딩하면 된다.

이렇게 해 주지 않으면 마지막으로 텍스트를 찍던 당시의 글자색 및 배경색이 무엇이냐에 따라서 focus 테두리의 색깔이 정확하게 반전색이 되는 게 아니라 들쭉날쭉 날뛰고 지저분해질 수 있다.
이건 꽤 중요한 사항인데 왜 MSDN 같은 문서에 전혀 소개되어 있지 않았나 모르겠다. 나도 10수 년째 모르고 있다가 요 얼마 전에야 깨달았다.

또한 50% 음영은 굉장히 단순하고 자주 쓰이는 패턴인데.. 브러시나 비트맵을 stock object로 제공을 좀 해 주지, 왜 안 하나 모르겠다. 요즘 같은 트루컬러, 알파채널 이러는 시대보다도 모노크롬, 16색 이러던 옛날에 더 필요했을 텐데 말이다.
CreateCaret 함수로 caret을 생성할 때는 일반적인 비트맵 핸들 대신 특수한 상수를 넣어서 50% 음영 모양을 지정하는 게 있는데.. caret보다는 다른 형태로 쓰이는 경우가 더 많다.

다음은 파란 배경에 대해서 잘못 그려진 테두리(위: 반전색+검정)와, 맞게 그려진 테두리(아래: 반전색+원래색)의 예시이다.

사용자 삽입 이미지

3. 비트맵 윤곽으로부터 region을 곧바로 생성하는 방법의 부재

Windows에서 region은 사각형이 아닌 임의의 비트맵 영역을 scan line들의 집합 형태로 표현하는 자료구조이며, 창을 사각형이 아닌 임의의 모양으로 만드는 데 쓰이는 수단이기도 하다. 이 블로그에서 예전에 한번 집중적으로 다룬 적이 있다. (☞ 예전 글)
Windows에서는 사각형이 아닌 임의의 복잡한 모양의 region을 생성하기 위해서 다각형, 원, 모서리간 둥근 사각형 등 여러 API를 제공하며, 집합 연산 비스무리하게 기존 region과 영역을 합성하는 CombineRgn이라는 함수도 제공한다.

그런데 이것만으로는 여전히 좀 2% 부족한 구석이 있다.
region을 생성할 때 사용되는 원· 다각형 그리기 함수의 결과와, 실제 DC에다 원· 다각형을 그리는 함수의 결과가 픽셀 단위로 100% 정확하게 일치하지 않을 때가 있다. 그래서 딱 정확하게 영역 안에다가 테두리를 깔끔하게 그리는 게 난감하다.

그리고 아예 만화 캐릭터 같은 모양의 창을 만들 때는.. 저렇게 벡터 이미지가 아니라 임의의 마스크 비트맵으로부터 그 윤곽 영역대로 region을 바로 생성할 수 있는 게 좋은데 의외로 그런 함수가 없다.

뭐, region의 내부 자료구조에 접근해서 복잡한 region을 직통으로 생성하는 방법도 없지는 않지만(정말 생짜 직사각형들의 집합..;; ) 이 역시 귀찮다는 건 어쩔 수 없다.
이 때문에 비트맵 그림으로부터 region을 생성하는 코드를 보면.. 비트맵 내용대로 한 줄 한 줄 CombineRgn(RGN_OR)로 한눈에 보기에도 정말 느리고 비효율적인 방법을 쓰고 있다.

layered window의 color key를 쓰면 투명색을 더 편리하게 구현할 수 있긴 하다. 허나, 창 아래의 그림자(CS_DROPSHADOW)는 region을 통해 지정된 경계하고만 정확하게 연계한다. 그렇기 때문에 애니메이션이 아닌 데서는 구닥다리 region도 여전히 필요하다.

이 분야는 다른 그래픽 API 같은 대안이 있는 것도 아닌데 마소에서 GDI API의 지원에 왜 이리 인식한지 모르겠다.;;

Posted by 사무엘

2021/03/28 08:35 2021/03/28 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1870

Windows 환경에서는 프로그램이 자기 화면(창)에다 뭔가를 그리고 표시하는 걸 보통은 WM_PAINT 메시지가 왔을 때 한다.
하지만 반드시 그때만 그림을 그릴 수 있는 건 아니다. 키보드나 마우스 입력(특히 뭔가 드래그)이 들어와서 특정 지점에 대한 시각 피드백만 즉각 주고 싶을 때, 혹은 타이머를 걸어서 일정 시간 주기로 반드시 뭔가를 그리고 싶을 때는 InvalidateRect라든가 WM_PAINT에 의존하지 않고, 프로그램이 직통으로 DC를 얻어 와서 그림을 그려도 된다.

화면 그리기뿐만 아니라 키보드 입력 인식도 마찬가지이다.
반드시 WM_KEYDOWN/UP 메시지를 통해서만 키보드 입력을 인식할 수 있는 건 아니다. 마우스 메시지를 처리 중일 때도 shift나 ctrl 같은 modifier key가 같이 눌렸는지, 혹은 caps/num/scroll lock 램프가 현재 켜져 있는지를 함수 호출 하나로 간편하게 알 수 있다.
그런 modifier 글쇠조차 매번 WM_KEYDOWN/UP때만 감지할 수 있다면.. 응용 프로그램이 지역 변수의 범위를 넘어서는 지저분한 key state 관리자를 둬야 할 것이고, 코딩이 굉장히 번거롭고 불편해질 것이다.

옛날에 도스 시절에 키 입력을 감지하는 건 꽤 번거로웠던 걸로 본인은 기억한다.
문자가 아닌 화살표, home/end, page up/down 같은 글쇠에 대해서는 0번(null) 문자가 prefix 명목으로 오고, 동일 함수를 한번 더 호출해서 실제 값(아마 스캔 코드)을 얻는 형태였다. 그러고 보니 저건 나름 dead key라는 개념이 구현된 셈이다.

그것 말고 ctrl이나 shift, 각종 lamp 글쇠는 저런 방식으로도 잡히지 않았기 때문에 또 다른 도스 API를 동원해야 했다. 요것들은 키보드 버퍼를 차지하지 않고, 컴퓨터가 바쁠 때 아무리 누르고 있어도 삑삑 소리를 발생시키지 않는 조용한 특수글쇠이기 때문이다.;;

글쇠를 누르는 것 말고 떼는 것을 감지하는 것도 본인은 도스 시절에 개인적으로 경험한 적이 없다.
글쇠를 누르고 있는 동안 해당 문자를 일정 간격으로 반복해서 접수해 주는 것은 컴퓨터 하드웨어 차원에서 행해지는 일인데.. 그런 키보드 속도에 구애받지 않고 누른 것과 뗀 것 자체만을 감지하는 건 특히 게임 만들 때의 필수 테크닉이다.
그랬는데 Windows에서는 모든 글쇠들이 한 치의 차별 없이 WM_KEYDOWN과 WM_KEYUP 메시지 앞에서 평등해지고 가상 키코드값을 부여받게 됐다니~! 정말 혁명 그 자체였다. 프로그래밍 패러다임이 싹 바뀌었다.

가상 키코드는 기반이 전적으로 소프트웨어에 있는 계층이기 때문에 같은 하드웨어에서도 차이가 날 수 있다. 가령, 같은 글쇠에다 가상 키코드를 부여하는 방식은 Windows와 mac이 서로 다를 수 있으며, Windows는 사용하는 키보드 드라이버에 따라서도 차이가 날 수 있다.

Windows의 가상 키코드는 caps lock 내지 shift의 영향을 받지는 않기 때문에 a건 A건 코드값이 같다. 하지만 num lock의 영향은 받기 때문에 키패드 0~9 숫자와 키패드 화살표의 코드값이 서로 다르다. 키패드 numlock 숫자는 진짜 숫자키의 숫자와도 가상 키코드가 다르다.
가상 키코드와 달리 스캔 코드는 각각의 물리적인 글쇠들에 고정불변으로 부여되어 있다. 좌우로 두 개 있는 shift처럼 가상 키코드가 동일한 글쇠는 스캔 코드로 방향을 구분할 수 있다.

요컨대 스캔 코드는 저수준이고 가상 키코드는 고수준이다. 여기에다가 문자 글쇠는 message loop에서 TranslateMessage 함수를 거침으로써 caps lock(대소문자)까지 고려한 실제 입력 문자가 담긴 WM_CHAR로 바뀐다.
WM_CHAR가 생성되는 과정(가상 키코드와 스캔 코드로부터 문자를 얻기)이 별도의 함수로 제공되기도 한다. 바로 ToUnicode 내지 ToAscii이다.

배경 설명이 좀 길어졌는데..
현재 어떤 글쇠가 눌러졌는지 여부를 알려주는 대표적인 함수는 GetKeyState이다. 인자로는 가상 키코드를 주면 되고, 리턴값으로는 2비트의 유의미한 정보가 담긴 BYTE값이 돌아온다.
최상위 비트 0x80은 이 key가 지금 눌렸는지의 여부이고, 최하위 비트 1은 눌렸다 뗐다 toggle 여부이다. 3대 lock들의 램프 점등 여부는 &1을 해 보면 알 수 있다.

심지어 GetKeyboardState 함수는 모든 가상 키코드값에 대한 키보드 상태를 배열 형태로 한꺼번에 되돌려 준다.
컴퓨터 키보드의 글쇠는 많아야 100여 개이지만 가상 키코드의 범위는 0~255라는 바이트 규모이므로 가상 키코드를 할당할 공간은 아주 넉넉한 셈이다.

그런데 Windows에는 GetAsyncKeyState라는 함수도 있다. 무엇이 비동기적이라는 얘기이며 GetKeyState와는 어떤 차이가 있는 걸까..?
GetKeyState는 현재 스레드의 메시지/input 큐 기준으로 WM_KEYDOWN/UP 메시지가 마지막으로 처리되었던 그 순간의 키보드 상태를 일관되게 쭉 되돌린다. 한 메시지가 처리되던 도중에 사용자가 어떤 글쇠를 누르거나 떼더라도 값이 변함없다.
한 컴퓨터에 키보드야 하나만 존재하겠지만, 각 응용 프로그램의 UI 스레드별 키보드 상태는 이론적으로 서로 제각각으로 다를 수 있다.

그 반면, GetAsyncKeyState는 그런 것과 상관없이 시스템 전체의 현재 키보드 상태를 실시간으로 반영해서 알려준다. 그리고 이유는 알 수 없지만 GetKey*는 최상위 bit 크기가 BYTE인 반면, GetAsyncKey*는 최상위 bit 크기가 WORD이다.
둘 다 함수의 리턴 타입은 short로 잡혀 있다. 하지만 전자는 눌려 있는 글쇠를 0x80으로 표현하는 반면, 후자는 0x8000으로 표현한다.

그러면 마우스 휠을 Ctrl을 누른 채로 굴렸는지 감지하고 싶을 때 GetKey*와 GetAsyncKey* 중 무엇을 쓰는 게 좋을까?
프로그램이 사용자의 키보드· 마우스 입력에 0.1초 안으로 정상적으로 반응하고 있는 상태라면 두 함수는 유의미한 차이를 보이지 않는다.

GetAsyncKey*는 내 프로그램이 작업을 하느라 수 초 동안 응답이 멎은 중에 사용자가 ESC를 누른 것 정도나 잡아내는 용도로 쓰면 된다. 아니면 애초에 자기 GUI 창이 없는 콘솔 프로그램에서 키 입력을 감지하는 것 말이다. 얘는 심지어 포커스가 다른 프로그램에 가 있을 때에도 특정 글쇠가 눌린 것을 감지할 수 있다.

이와 달리 GetKey*는 메시지 처리 단위로 실행 결과가 '동기화'돼 있으며, 정확하게 자기 스레드의 UI에 포커스가 가 있을 때 글쇠가 눌린 것만 감지해 준다. 그러니 일반적인 상황에서 우리에게 필요한 것은 대체로 GetAsyncKey*가 아니라 그냥 GetKey*이다.

Async가 붙은 놈이건 안 붙은 넘이건, 이들 함수는 글쇠가 눌린 것을 감지만 하지, 그걸 처리한 것으로 퉁쳐 주지는 않는다. 내 작업 루틴에서 ESC가 눌린 것을 감지해서 하던 작업을 중단했다 하더라도 UI에서 WM_KEYDOWN + VK_ESCAPE 메시지가 가는 것은 변함없다.
이럴 거면 GetAsyncKey*를 호출할 게 아니라 Peek/Get/DispatchMessage로 메시지를 정식으로 처리하는 게 더 낫다. GetAsyncKey*는 쓸 일이 더욱 줄어드는 셈이다.

Posted by 사무엘

2021/03/20 08:35 2021/03/20 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1867

지금으로부터 30년쯤 전에 도스용으로 만들어졌던 프로그래밍 툴 중에는 자기 언어로 만들어진 예제 프로그램으로 그럴싸한 게임을 제공하는 경우가 있었다.
QBasic의 경우, 포트리스 내지 Scorched Earth와 비슷한 형태의 턴 기반 슈팅인 '고릴라'가 유명했으며.. 길다란 뱀을 사방으로 적절히 조종하면서 아이템(?)을 먹는 퍼즐인 nibbles도 있었다.

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

아이템을 먹을수록 뱀은 길이가 더 길어지며, 머리가 벽은 물론이고 자기 몸통과도 부딪치지 않도록 조종을 잘 해야 한다. 그리고 레벨이 올라갈수록 뱀의 이동 속도가 더 올라가고 장애물도 더 많아져서 게임 진행이 더 어려워진다.
영문판 원판은 80*25 텍스트 화면에서도 아스키 그래픽 문자를 적절히 이용해서 글자 한 칸을 상하로 쪼개어 세로 공간을 두 배로 늘리는 편법을 구현했다. 하지만 한글판에서 제공된 nibbles는 문자 코드의 한계로 인해 그런 게 다 삭제되었다.

그런데 가만히 생각해 보니 마소 말고 볼랜드 개발툴에서 제공한 예제 프로그램 중에는 가히 이 분야의 끝판왕이 있었다. 번듯한 체스 게임이 컴퓨터 AI까지 포함해서 소스가 통째로 제공되었던 것이다.

사용자 삽입 이미지

이거 기억하는 분 계신가..?
그런데 이게 bgidemo보다 훨씬 덜 유명하고, 본인도 지난 수십 년 동안 얘의 존재를 까맣게 잊고 있었던 이유는.. 아무래도 아무 버전에서나 쉽게 볼 수 있는 예제는 아니었기 때문이지 싶다.
즉, 보급형인 Turbo가 아니라 기함급인 Borland라는 브랜드가 붙은 C++ 내지 Pascal을 설치하고, Windows 개발 환경에다 자체 프레임워크 라이브러리까지 다 선택해야 얘를 구경하고 돌려볼 수 있다.

이 예제 프로그램의 이름은 볼랜드에서 개발한 C++용 Windows API 프레임워크의 이름을 딴 OWL Chess였다.
하지만 내 기억이 맞다면 Turbo Vision 기반의 도스용 체스 예제도 있었다. 체스판과 말을 그래픽 모드가 아니라 텍스트 모드에서 꽤 기괴한 색과 특수문자를 동원해서 표현했던 걸로 기억하는데.. 정확한 내역은 너무 오래돼서 잘 모르겠다.

Windows용 OWL Chess는 이런 식으로 동작했던 걸로 본인은 기억한다.

  • 16비트 전용이다. 32비트 에디션에도 포함됐다거나, Delphi 및 C++ Builder 같은 후대의 컴포넌트 기반 RAD툴로 리메이크 됐다는 소식은 내가 아는 한 없다. 그러니 얘는 Windows XP에서 실행됐을 때도, 저 스크린샷에서 보다시피 프로그램의 제목 표시줄에 테마가 적용돼 있지 않다.
  • 역시 저 스크린샷에서 묘사된 바와 같이, 창 크기는 고정 불변이다. 요즘처럼 모니터가 크고 화면 해상도가 높은 시대엔 크기 조절이 안 되는 프로그램은 사용자에게 좋은 인상을 주기 어려울 것이다.
  • 키보드 포커스가 딴데로 넘어가서 프로그램이 비활성화 되면 즉시 게임판이 가려지고 pause 모드로 바뀐다.
  • 컴퓨터 AI는 1990년대의 바둑 같은 보드 게임 AI들이 그랬던 것처럼 규칙 기반으로 move를 평가하고, 재귀적으로 수읽기를 하면서 알파-베타 가지치기로 복잡도를 제어하는 식으로 구현됐다. 생각하는 데 시간이 많이 걸리긴 하지만, 멀티스레드라는 것도 없던 시절에 이 동작이 찔끔찔끔 idle time processing만으로 잘 만들어져 있다. 컴퓨터의 생각이 현재 어느 정도까지 진행됐는지가 수시로 현란하게 시각적으로 표시되기 때문에 지겹지 않다.

하긴, 1990년대 초중반에는 프로그래밍깨나 공부 좀 한 사람들이 도스의 그래픽 모드에서 아기자기한 오목· 장기 게임을 구현해서 PC 통신 자료실에 무료로 공개한 게 많았다. 아, 심지어 화투 치는 고도리...도 그 시절부터 있었다.
또한 그 시절에 유명한 프로그래밍 기술 간행물이던 '비트 프로젝트' 시리즈에도 초창기엔 Borland C++로 개발한 Windows용 장기 게임이 있었다.

지금이야 국내에서 유료 판매까지 되고 있는 장기 게임 프로그램으로는 '장기도사'가 있다. 하지만 그 전에는 '바다장기'라는 프로그램도 있었는데, 얘가 내 기억이 맞다면 저 원조 OWL Chess의 소스를 기반으로 만들어진 듯했다.
프로그램의 외형과 동작이 굉장히 비슷하게 느껴졌었기 때문이다. 또한 바다장기도 검색을 해 보면 16비트스러운 스크린샷밖에 안 나오는 게 더욱 비슷하다.

사용자 삽입 이미지

그래도 서양의 체스와 동양의 장기가 완전히 동일한 게임은 아닐 텐데, 체스 AI를 장기 AI로 룰을 개조하는 건 건 아무나 할 수 있는 일이 아니었을 것이다. 그리고 그 원판 AI 코드도 move를 기술하고 평가하는 룰 계층만 바꿔 주면 어지간한 보드 게임의 AI에 모두 대응 가능하도록 상당히 추상적이고 깔끔하게 잘 만들어져 있었던 모양이다. 바다장기는 AI를 '추론 엔진'이라는 용어를 써서 표현했다.

일개 예제 프로그램의 체스 AI가 전문 상업용 AI에 비할 바는 아니겠지만.. 이 정도만 해도 어디인가? 지금 저 프로그램의 소스를 다시 볼 수 있으면 보드 게임 AI의 구현과 관련해서 많은 통찰을 얻을 수 있을 텐데 아쉽다. 얘의 소스만 어디 github에 따로 올라와도 될 텐데 말이다.
본인은 체스는 룰조차도 모르지만.. 그래도 학창 시절에 오목과 스크래블이라는 보드 게임 AI를 연구했던 이력이 있는 사람이어서 이런 쪽에 더욱 흥미를 느낀다.

Posted by 사무엘

2021/03/17 19:35 2021/03/17 19:35
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1866

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/11   »
          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:
2988741
Today:
301
Yesterday:
1477