남의 코드를 읽으면서 신기하게 느꼈던 점들을 다음과 같이 정리했다. 요즘 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

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

C/C++은 성능을 위해 컴파일러나 언어 런타임이 프로그래머의 편의를 위해 뭔가를 몰래 해 주는 것을 극도로 최소화한 언어이다. 꾸밈 없고 정제되지 않은 '날것 상태 raw state'에다가 고급 프로그래밍 언어의 패러다임과 문법만을 얹은 걸 추구하다 보니, '초기화되지 않은 쓰레기값'이라는 게 존재하는 거의 유일한 언어이기도 하다.

같은 프로그램이라도 이제 막 선언된 변수나 할당된 메모리 안에 무슨 값이 들어있을지는 컴퓨터 운영체제의 상태에 따라서 그때 그때 달라진다. 반드시 0일 거라는 보장은 전혀 없다. 오히려 0 초기화는 별도의 CPU 부담이 필요한 인위적인 작업이다. global/static에 속하는 메모리만이 무조건적인 0 초기화가 보장된다.

그렇다고 쓰레기값이라는 게 완벽하게 예측 불가이면서 통계적인 질서를 갖춘 난수인 것도 물론 아니다.
그리고 Visual C++에서 프로그램을 debug 세팅으로 빌드하면 쓰레기값이라는 게 크게 달라진다. C++ 개발자라면 이 사실을 이미 경험적으로 충분히 알고 있을 것이다.

1. 갓 할당된 메모리의 기본 쓰레기값: 0xCC(스택)와 0xCD(힙)

가장 먼저, 스택에 저장되는 지역변수들은 자신을 구성하는 바이트들이 0xCC로 초기화된다. 다시 말해 디버그 빌드에서는 int x,y라고만 쓴 변수는 int x=0xCCCCCCCC, y=0xCCCCCCCC와 얼추 같게 처리된다는 것이다. 디스어셈블리를 보면 의도적으로 0xCC를 대입하는 인스트럭션이 삽입돼 들어간다.

0은 너무 인위적이고 유의미하고 깔끔한(?) 값 그 자체이고, 그 근처에 있는 1, 2나 0xFF도 자주 쓰이는 편이다. 그에 비해 0xCC는 형태가 단순하면서도 현실에서 일부러 쓰일 확률이 매우 낮은 값이다. 그렇기 때문에 여기는 초기화되지 않은 쓰레기 영역이라는 것을 시각적으로 곧장 드러내 준다.

int a[10], x; a[x]=100; 같은 문장도 x가 0으로 깔끔하게 자동 초기화됐다면 그냥 넘어가지만, 기괴한 쓰레기값이라면 곧장 에러를 발생시킬 것이다.
또한, 복잡한 클래스의 생성자에서 값이 대입되어 초기화된 멤버와 그렇지 않은 멤버가 뒤섞여 있을 때, 0xCC 같은 magic number는 탁월한 변별력을 발휘한다.

타겟이 align을 꼼꼼하게 따지는 아키텍처라면, 쓰레기값을 0x99나 0xDD 같은 홀수로 지정하는 것만으로도 초기화되지 않은 포인터 실수를 잡아낼 수 있다. 32비트에서 포인터값의 최상위 비트가 커널/사용자 영역을 분간한다면, 최하위 비트는 align 단위를 분간할 테니 말이다. 뭐 0xCC는 짝수이다만..

0xCC라는 바이트는 x86 플랫폼에서는 int 3, 즉 breakpoint를 걸어서 디버기의 실행을 중단시키는 명령을 나타내기도 한다. 그래서 이 값은 실행 파일의 기계어 코드에서 align을 맞추기 위해 공간만 차지하는 잉여 padding 용도로 위해 듬성듬성 들어가기도 한다.
Visual C++ 6 시절엔 거기에 말 그대로 아무 일도 안 하는 nop를 나타내는 0x90이 쓰였지만 2000년대부터는 디버깅의 관점에서 좀 더 유의미한 작용을 하는 0xCC로 바뀐 듯하다. 정상적인 상황이라면 컴퓨터가 이 구역의 명령을 실행할 일이 없어야 할 테니까..

다만, 힙도 아니고 스택 지역변수의 내용이 데이터가 아닌 코드로 인식되어 실행될 일이란 현실에서 전혀에 가깝게 없을 테니, 0xCC는 딱히 x86 기계어 코드를 의식해서 정해진 값은 아닐 것으로 보인다.

Visual C++의 경우, 스택 말고 malloc이나 new로 할당하는 힙(동적) 메모리는 디버그 버전에서는 내용 전체를 0xCD로 채워서 준다. 0xCC보다 딱 1 더 크다. 아하~! 이 값도 평소에 디버그 하면서 많이 보신 적이 있을 것이다.
힙의 관리는 컴파일러 내장이 아니라 CRT 라이브러리의 관할로 넘어기도 하니, 0xCD라는 값은 라이브러리의 소스인 dbgheap.c에서도 _bCleanLandFill이라는 상수명으로 확인할 수 있다.

2. 초기화되지 않은 스택 메모리의 사용 감지

C/C++ 언어는 지역변수의 값 초기화를 필수가 아닌 선택으로 남겨 놓았다. 그러니 해당 언어의 컴파일러 및 개발툴에서는 프로그래머가 초기화되지 않은 변수값을 사용하는 것을 방지하기 위해, 디버그용 빌드 한정으로라도 여러 안전장치들을 마련해 놓았다.
그걸 방지하지 않고 '방치'하면 같은 프로그램의 실행 결과가 debug/release별로 달라지는 것을 포함해 온갖 골치아픈 문제들이 발생해서 프로그래머를 괴롭히게 되기 때문이다.

int x; printf("%d", x);

이런 무식한 짓을 대놓고 하면 30년 전의 도스용 Turbo C에서도 컴파일러가 경고를 띄워 줬다. Visual C++의 에러 코드는 C4700. 그랬는데 한 Visual C++ 2010쯤부터는 이게 경고가 아니라 에러로 바뀌었다.
그리고 그 뿐만이 아니다.

int x;
if( some_condition_met(...)) x=0;
printf("%d", x);

이렇게 문장을 약간만 꼬아 놓으면 초기화를 전혀 안 하는 건 아니기 때문에 컴파일 과정에서의 C4700을 회피할 수 있다. 하지만 보다시피 if문의 조건이 충족되지 않으면 x가 여전히 초기화되지 않은 채 쓰일 수 있다. 이건 정적 분석 정도는 돌려야 감지 가능하다.
(그런데, 글쎄.. 함수의 리턴이 저런 식으로 조건부로 불완전하게 돼 있으면 컴파일만으로도 C4715 not all control paths return value 경고가 뜰 텐데.. 비초기화 변수 접근 체크는 그 정도로 꼼꼼하지 않은가 보다.)

Visual C++은 /RTC라고 디버그용 빌드에다가 run-time check라는 간단한 검사 기능을 추가하는 옵션을 제공한다. 함수 실행이 끝날 때 스택 프레임 주변을 점검해서 버퍼 오버런을 감지하는 /RTCs, 그리고 지역변수를 초기화하지 않고 사용한 것을 감지하는 /RTCu.

사용자 삽입 이미지

저 코드를 Visual C++에서 디버그 모드로 빌드해서 실행해서 if문이 충족되지 않으면 run-time check failure가 발생해서 프로그램이 정지한다. 다만, 이 메모리는 초기화만 되지 않았을 뿐 접근에 법적으로 아무 문제가 없는 스택 메모리이다. 할당되지 않은 메모리에 접근해서 access violation이 난 게 아니다. 심각한 시스템/물리적인 오류가 아니라 그저 의미· 논리적인 오류이며, 쓰기를 먼저 하지 않은 메모리에다가 읽기를 시도한 게 문제일 뿐이다.

그러니 이 버그는 해당 메모리 자체에다가 시스템 차원의 특수한 표식을 해서 잡아낸 게 아니며, 논리적으로 매우 허술하다. (0xCC이기만 하면 무조건 스톱.. 이럴 수도 없는 노릇이고!)
문제의 코드에 대한 디스어셈블리를 보면 if문이 만족되지 않으면 printf으로 가지 않고 그냥 곧장 RTC failure 핸들러를 실행하게 돼 있다.

void do_nothing(int& x) {}

int x; do_nothing(x); printf("%d", x);

그렇기 때문에 요렇게만 해 줘도 RTC를 회피하고 x의 쓰레기값을 얻는 게 가능하다. 글쎄, 정교한 정적 분석은 이것도 지적해 줄 수 있겠지만, 포인터가 등장하는 순간부터 메모리 난이도와 복잡도는 그냥 하늘로 치솟는다고 봐야 할 것이다.

하물며 처음부터 포인터로만 접근하는 힙 메모리는 RTC고 뭐고 아무 안전 장치가 없다. int *p에다가 new건 malloc이건 값이 하나 들어간 것만으로도 초기화가 된 것이거늘, 그 주소가 가리키는 p[0], p[1] 따위에 쓰레기값(0xCD)이 있건 0이 있건 알 게 무엇이겠는가????

나도 지금까지 혼동하고 있었는데, 이런 run-time check failure는 run-time error와는 다른 개념이다. 순수 가상 함수 호출 같은 건 C/C++에 존재하는 얼마 안 되는 run-time error의 일종이고 release 빌드에도 포함돼 들어간다. 하지만 RTC는 debug 빌드 전용 검사이다.

그러니 버퍼 오버런을 감지하는 보안 옵션이 /RTC만으로는 충분하지 않고 /GS가 따로 있는 것이지 싶다. /GS는 release 빌드에도 포함돼 있으며, 마소에서는 보안을 위해 모든 프로그램들이 이 옵션을 사용하여 빌드할 것을 권하고 있다.

3. 해제된 힙 메모리: 0xDD(CRT)와 0xFEEE(???)

일반적인 프로그래머라면 동적으로 할당받은 힙 메모리를 free로 해제했을 때, 거기를 가리키는 메모리 영역이 실제로 어떻게 바뀌는지에 대해 생각을 별로 하지 않는다. 사실, 할 필요가 없는 게 정상이기도 하다.
우리 프로그램은 free를 해 준 주소는 신속하게 영원히 잊어버리고, 그 주소를 보관하던 포인터는 NULL로 바꿔 버리기만 하면 된다. free 해 버린 주소를 또 엿보다가는 곧바로 메모리 에러라는 천벌을 받게 될 것이다.

그런데 실제로는, 특히 디버그 모드로 빌드 후 프로그램을 디버깅 중일 때는 free를 한 뒤에도 해당 메모리 주소가 가리키는 값을 여전히 들여다볼 수 있다. 들여다볼 수 있다는 말은 *ptr을 했을 때 access violation이 발생하지 않고 값이 나온다는 것을 의미한다.
이 공간은 나중에 새로운 메모리 할당을 위해 재사용될 수야 있다. 하지만 사용자가 디버깅의 편의를 위해 원한다면 옵션을 바꿔서 재사용되지 않게 할 수도 있다. (_CrtSetDbgFlag(_CRTDBG_DELAY_FREE_MEM_DF) 호출)

뭐, 메모리를 당장 해제하지 않는다고 해서 free 하기 전의 메모리의 원래 값까지 그대로 남아 있지는 않는다. Visual C++의 디버그용 free/delete 함수는 그 메모리 블록의 값을 일부러 0xDD (_bDeadLandFill)로 몽땅 채워 넣는다. 여기는 할당되었다가 해제된 영역임을 이런 식으로 알린다는 것이다.

실제로, free된 메모리가 곧장 흔적도 없이 사라져서 애초에 존재하지도 않았던 것처럼 접근 불가 ?? 로 표시되는 것보다는 0xDD라고 디버거의 메모리 창에 뜨는 게 dangling pointer 디버깅에 약간이나마 더 도움이 될 것이다. 이 포인터가 처음부터 그냥 쓰레기값을 가리키고 있었는지, 아니면 원래는 valid하다가 지칭 대상이 해제되어 버린 것인지를 분간할 수 있으니 말이다.

그런데 본인은 여기서 개인적으로 의문이 들었다.
본인은 지난 20여 년에 달하는 Visual C++ 프로그래밍과 메모리 문제 디버깅 경험을 떠올려 봐도.. 갓 할당된 쓰레기값인 0xCC와 0xCD에 비해, 0xDD를 본 적은 전혀 없는 건 아니지만 매우 드물었다.

dangling pointer가 가리키는 메모리의 값은 0xD?보다는 0xF?였던 적이 훨씬 더 많았다. 더 구체적으로는 2바이트 간격으로 0xFEEE (0xEE, 0xFE)이다.

사용자 삽입 이미지

인터넷 검색을 해 보니.. 이건 놀랍게도 CRT 라이브러리가 채워 넣는 값이 아니었다. free/delete가 궁극적으로 호출하는 Windows API 함수인 HeapFree가 메모리를 정리하면서 영역을 저렇게 바꿔 놓았었다. 더구나 CRT에서 0xDD로 먼저 채워 넣었던 영역을 또 덮어쓴 것이다.
이 동작에 대해서 놀라운 점은 저게 전부가 아니다.

(1) 0xFEEE 채우기는 프로그램을 Visual C++ 디버거를 붙여서(F5) 실행했을 때만 발생한다. debug 빌드라도 디버거를 붙이지 않고 그냥 Ctrl+F5로 실행하면 0xFEEE가 생기지 않는다. 그리고 release 빌드라도 디버거를 붙여서 실행하면 0xFEEE를 볼 수 있다.

(2) 더 놀라운 점은.. 내가 집과 직장 컴퓨터를 통틀어서 확인한 바로는 저 현상을 볼 수 있는 건 Visual C++ 2013 정도까지이다. 2015부터는 debug 빌드를 디버거로 붙여서 돌리더라도 0xFEEE 채움이 발생하지 않고 곧이곧대로 0xDD만 나타난다~!

운영체제가 정확하게 어떤 조건 하에서 0xFEEE를 채워 주는지 모르겠다. 인터넷 검색을 해 봐도 정확한 정보가 나오는 게 의외로 없다.
하필 Visual C++ 2015부터 저런다는 것은 CRT 라이브러리가 Universal CRT니 VCRuntime이니 하면서 구조가 크게 개편된 것과 관계가 있지 않으려나 막연히 추측만 해 볼 뿐이다.

여담이지만 HeapAlloc, GlobalAlloc, LocalAlloc은 연달아 호출했을 때 돌아오는 주소의 영역이 그리 큰 차이가 나지 않으며, 내부 동작 방식이 모두 비슷해진 것 같다. 물론 뒤의 global/local은 fixed 메모리 할당 기준으로 말이다.

4. 힙 메모리 영역 경계 표시용: 0xFD와 0xBD

0xCD, 0xDD, (0xFEEE) 말고 heap 메모리 주변에서 볼 수 있는 디버그 빌드용 magic number 바이트로는 0xFD _bNoMansLandFill와 0xBD _bAlignLandFill가 더 있다.

얘들은 사용자가 요청한 메모리.. 즉, 0xCD로 채워지는 그 메모리의 앞과 뒤에 추가로 고정된 크기만큼 채워진다. Visual C++ CRT 소스를 보면 크기가 NoMansLandSize인데, 값은 4바이트이다. 사용자가 요청한 메모리 크기에 비례해서 채워지는 0xCD와 0xDD에 비하면 노출 빈도가 아주 작은 셈이다. 특히 0xBD는 0xFD보다도 더욱 듣보잡인 듯..

애초에 얘는 사용자가 건드릴 수 있거나 건드렸던 공간이 아니며 그 반대이다. 사용자는 0xCD로 채워진 공간에다가만 값을 집어넣어야지, 앞뒤  경계를 나타내는 0xFD를 건드려서는 안 된다.
CRT 라이브러리의 디버그용 free/delete 함수는.. 힙을 해제할 때 이 0xFD로 표시해 놨던 영역이 값이 바뀌어 있으면 곧장 에러를 출력하게 돼 있다.

그리고 예전에 메모리를 해제해서 몽땅 0xDD로 채워 놨던 영역도 변조된 게 감지되면 _CrtCheckMemory 같은 디버깅 함수에서 곧장 에러를 찍어 준다. 그러니 0xDD, 0xFD, 0xBD는 모두 오류 검출이라는 용도가 있는 셈이다. 0xCC와 0xCD 같은 쓰레기값 영역은 쓰지도 않고 곧장 읽어들이는 게 문제이지만, 나머지 magic number들은 건드리는 것 자체가 문제이다.

그리고 얘들은 heap 메모리를 대상으로 행해지는 점검 작업이다. 이런 것 말고 스택 프레임에다가 특정 magic number를 둬서 지역변수 배열의 overflow나 복귀 주소 변조를 감지하는 것은 별도의 컴파일러 옵션을 통해 지원되는 기능이다. 요것들은 힙 디버그 기능과는 별개이며, 보안 강화를 위해 release 빌드에도 포함되는 게 요즘 추세이다.

이상이다.
파일 포맷 식별자 말고 메모리에도 디버깅을 수월하게 하기 위해 쓰레기값을 가장한 이런 특수한 magic number들이 쓰인다는 게 흥미롭다. Windows의 Visual C++ 외의 다른 개발 환경에서는 디버깅을 위해 어떤 convention이 존재하는지 궁금해진다.

사실, 16진수 표기용인 A~F에도 모음이 2개나 포함돼 있고 생각보다 다양한 영단어를 표현할 수 있다. 거기에다 0을 편의상 O로 전용하면 모음이 3개나 되며, DEAD, FOOD, BAD, FADE, C0DE 정도는 거뜬히 만들어 낸다. 거기에다 FEE, FACE, FEED, BEEF 같은 단어도.. 유의미한 magic number나 signature를 고안하는 창의력을 발산하는 데 쓰일 수 있다.
그러고 보니 아까 0xFEEE도 원래 free를 의도했는데 16진수 digit에 R은 없다 보니 불가피하게 0xFEEE로 대충 때운 건지 모르겠다.

Posted by 사무엘

2021/02/11 08:36 2021/02/11 08:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1853

1. 가상 함수 테이블을 강제로 없애기

C++에서는 가상 함수가 하나 이상 존재하는 클래스는 그렇지 않은 클래스와 좀 다른 취급을 받게 된다. 자기가 새로 선언한 것뿐만 아니라 기반 클래스로부터 상속받은 가상 함수까지 포함해서 말이다.
가상 함수가 존재하는 모든 클래스들은 각각 가상 함수 포인터 테이블(v-table)이라는 일종의 global 배열을 할당받으며, 이를 가리키는 포인터를 멤버로 갖게 되고 생성자에서 그 포인터의 값이 초기화된다.

클래스에 온갖 파생 클래스와 가상 함수들이 추가되고 상속 단계가 복잡해질수록, 이 테이블들의 개수와 크기도 무시 못 할 비중을 차지하게 된다.
A라는 클래스에 가상 함수가 5개 있으면 A의 v-table은 크기가 5이다. 그런데 A로부터 상속받은 클래스 B가 또 가상 함수를 3개 추가한다면 B의 v-table의 크기는 5+3 = 8이 된다. 그런 식이다. 다중· 가상 상속 같은 것까지 자연스럽게 지원하려면 B의 v-table에도 A의 것이 고스란히 몽땅 중복 등재돼 있어야 한다.

그런데 문제는 순수 가상 함수가 들어가 있어서 단독으로 인스턴스가 생성될 일이 전혀 없는 클래스에 대해서도 일단은 동작의 일관성을 보장하기 위해 v-table이 잉여스럽게 따로 생성된다는 것이다.
왜냐하면 B라는 클래스의 생성자는 그 정의상 기반 클래스 A의 생성자를 반드시 호출하게 돼 있고, A의 생성자가 하는 일 중 하나는 A에 대한 고유한 v-table을 참조하는 것이기 때문이다.

어차피 값이 거의 전부 다 NULL이고 실제로 쓰일 일도 없는 v-table이 쓸데없이 생성되는 일을 막기 위해, Visual C++에는 __declspec(novtable)이라는 비표준 확장 속성이 도입되었다. 특히 COM이라는 규격을 C++ 언어와 바이너리 차원에서 연계하려다 보니 이 기능이 없어서는 도저히 안 되는 지경이 되었다.

굳이 순수 가상 함수가 없더라도, 어쨌든 반드시 파생 클래스 형태로만 쓰이지 그 자체가 인스턴스 형태로 선언될 일이 없는 클래스라면 __declspec(novtable)를 지정해 주는 게 메모리 절약 측면에서 좋다. 날개셋 한글 입력기의 경우 이걸 적용해 주니 ngs3.dll의 크기가 수 KB 남짓 줄어드는 효과가 있었다.

특히 데이터 멤버 없고 순수 가상 함수밖에 없는 인터페이스라면 이거 지정이 무조건 필수이다. 이래서 후대의 객체지향 언어들은 구조체(생성자 소멸자 가상 함수 없는 POD) vs 클래스뿐만 아니라, 일반 클래스 vs interface(추상 클래스)도 언어 차원에서 구분한다는 게 느껴졌다. 이게 단순히 외형이나 기분상의 구분만을 의미하지 않는다.

파생 클래스 쪽의 초기화가 덜 된 오브젝트에 대해서 순수 가상 함수를 호출해 버리면 보통은 pure virtual function call이라는 C++ 예외가 발생한다. 그런데 __declspec(novtable)가 지정된 오브젝트에 대해서 가상 함수를 호출해 버리면 저런 high-level 예외가 아니라 그냥 NULL 포인터 접근에 준하는 평범한 access violation 에러가 나면서 프로그램이 죽게 된다.

2. 가상 함수 테이블의 메모리 오버헤드

가상 함수 테이블 얘기를 계속하겠다.
B가 가상 함수가 100개 있는 A라는 클래스를 상속받아서 자신만의 가상 함수를 새로 만드는 것 없이, A의 함수 중 딱 한두 개만 override를 했더라도.. 컴파일러는 내용이 한두 개밖에 차이가 나지 않고 거의 동일한 원소 100개짜리 함수 포인터 배열을 또 하나 만들게 된다. B의 모든 인스턴스들이 한데 공유하는 가상 함수 포인터 테이블 말이다. 일종의 copy-on-modify와 비슷한 메커니즘이다.

그렇게 해야만 A건 B건 오브젝트 종류를 불문하고 가상 함수의 주소를 배열 오프셋만으로 O(1) 시간 만에 곧장 얻어 와서 호출을 할 수 있기 때문이다.
가상 함수 호출은 그렇잖아도 오버헤드가 일반 함수 호출보다 훨씬 더 큰데, 메모리를 아낀답시고 주소를 얻는 것조차 클래스 계층이나 함수 테이블을 뒤지는 방식이어서는 곤란하다.

하지만 이런 내부 디테일과 오버헤드를 생각하면 클래스를 아무 부담 없이 막 상속받기가 좀 부담스러워진다. 뭐, 요즘처럼 컴퓨터에 메모리가 풍족한 시대에 무슨 198, 90년대 같은 메모리 구두쇠 쫌생이처럼 코딩을 할 필요는 없지만, 그래도 같은 값이면 컴퓨터의 자원을 아껴 쓰는 게 프로그래머의 미덕 아니겠는가?

그렇다고 언어의 스펙을 바꿔 버릴 수는 없으니, Windows용 C++ 라이브러리인 MFC는 저런 가상 함수 테이블 오버헤드를 줄이기 위해, CWnd 클래스의 Windows 메시지 handler 함수는 가상 함수 대신 pointer-to-member 기반의 message map으로 구현한 것으로 잘 알려져 있다.

얘는 사용자가 실제로 overriding을 한 함수의 개수만큼만 테이블의 크기가 커지니 공간 사용은 효율적이다. 그러나 메시지에 대응하는 함수를 찾는 일은 매번 테이블을 선형 검색을 해야 하기 때문에 시간 복잡도가 O(n)으로 커진다.
이 부분은 정말 밥 먹듯이 자주 호출되는 부분이고 목숨 걸고 성능을 최적화해야 하는지라, MFC 소스에서도 극히 이례적으로 인라인 어셈블리가 사용되어 있다~! (wincore.cpp의 AfxFindMessageEntry 함수)

객체지향을 구현하기 위해서 이런 시간-공간 tradeoff를 따져 봐야 하니, C++보다 더 유연한 디자인을 추구한 Objective C 같은 언어는 메시지를 아예 문자열로 식별하게 했다(selector). 이름으로부터 실제 주소 매핑은 물론 hash 기반이고..

C++은 배열 오프셋 기반이어서 당장 성능 자체는 제일 좋으나, 클래스에서 뭐가 바뀌었다 싶으면 몽땅 재컴파일 해야 한다. 그리고 그런 경직된 체계에서 다중/가상 상속과 멤버 함수 포인터까지 구현하려다 보니 디자인에 무리수가 많이 들어갔고, 그 뒷감당은 결국 컴파일러 제조사들이 비표준 확장을 도입해서 땜빵하는 촌극이 벌어졌다.

그에 비해 Objective C는 신호등 교차로 대신 회전 교차로, 수동 변속기 대신 아예 자동 변속기 같은 접근을 한 셈인데.. C++ 같은 C의 이념 적당히 절충이 아니라 객체지향 언어라는 이론만 생각해 보면 저런 접근 방식도 충분히 가능은 하겠다는 생각이 든다.

3. 캐사기 auto

람다 함수가 최초로 도입된 C++0x 시절에 곧장 가능했던 것 같지는 않다만..
요즘 람다는 인자와 리턴값의 타입으로 auto를 지정할 수 있다.. >_<
auto에서 또 auto가 가능하다니? 그것도 C++ 같은 언어에서.. 이건 좀 꽤 사악한 기능인 것 같다.

auto Func = [](auto x) -> auto { return x * 2; };
printf("%f\n", Func(1.4));
printf("%d\n", Func(25));

람다가 일반 C++ 함수 같은 오버로딩이나 템플릿을 지원하지는 않으나, 저기서는 auto가 템플릿 인자 역할을 하는 거나 마찬가지이다.
생긴 건 함수와 비슷하지만 캡처가 지원되고 호출 규약 제약 없고 저런 것까지 가능하니.. 기존의 함수와는 형태가 얼마나 다른지 알 수 있다.

그나저나 람다 함수는 함수 자기 자신을 지칭해서 재귀 호출을 하는 방법은 여전히 없는지? this처럼 자기 함수 자신을 가리키는 예약어가 필요하지 않을까 싶다. __self??
그리고 사실은 C++도 기반 클래스를 지칭하는 __super 같은 키워드도 있어야 할 듯하다.

4. export의 재탄생

C++에서 완전 존재감 없는 잉여로 전락했던 auto가 10여 년 전 2010년대부터는 잘 알다시피 언어의 패러다임의 변화를 주도하는 거물로 환골탈태했다.
그것처럼.. 지난 2000년대에 비현실적인 흑역사 키워드로 전락하고 봉인됐던 키워드 export가.. 2020년대부터는 모듈 선언이라는 완전히 다른 용도로 다시 쓰이게 될 것으로 보인다. 하긴 import, export는 그 대상이 무엇이 됐건 프로그래밍에서는 굉장히 즐겨 쓰일 만한 의미의 동사이긴 하다.

지난 수십 년을 C처럼 원시적인 텍스트 include와 라이브러리 링킹에 의존했던 C++에 파스칼이나 D처럼 신속하게 빌드 가능한 모듈, 유닛이 도입되려는가 궁금하다.
그럼 이제 C/C++에서 제일 존재감 없는 잉여 키워드로는 signed만 남는 건지?

C++이 이 정도까지 변하고 있는데 #pragma once는 좀 언어 차원에서 정식으로 표준으로 수용할 생각이 없는지 궁금하다.
이제 플랫폼을 불문하고 지원하지 않는 컴파일러가 거의 없는 사실상의 표준이며, 실용적으로도 꼭 필요한 기능인데 말이다.

5. 템플릿의 prtial specialization

C++ 템플릿에는 template<typename X> class A처럼 말 그대로 템플릿을 선언하는 게 있는가 하면, template<> class A<XXX> 처럼 특정 타입에 대해 specialization을 하는 문법이 있다. 둘의 형태를 주목하기 바란다.
그런데 C++03즈음에서는 partial specialization이라고 해서 단일 타입이 아니라 일정 조건을 만족하는 템플릿 인자에 대해서는 몽땅 이 specialization을 사용하도록 하는 기능이 추가되었다. 가령 X가 포인터 타입이라든가, 템플릿 인자 A,B를 받는데 B가 A 내부의 pointer-to-member 타입이라거나 할 때 말이다.

이런 specialization에서는 template<typename X> class A<X*> 내지 A<X, X::*> 같은 식으로 template과 A에 템플릿 인자가 모두 들어온다.
개인적으로 이런 기능을 사용해 본 적은 없다. 특정 상황에서 편리한 기능이 될 수는 있겠지만.. 컴파일러를 만드는 건 더욱 어려워질 것 같다..;; 저 시절에 이런 기능과 함께 export까지 제안됐었다니 참 가관이다.

6. C++ 코딩 중에 주의해야 할 점

예전 글에서 언급했던 것들이지만 다시 한데 정리해 보았다.
참고로, 클래스의 상속 관계에서 부모/자식(parent/child), 기반/파생(base/derived)은 서로 기능적으로 아무 차이 없이 기분 따라 섞여서 쓰인다.
인자와 매개변수도(argument/parameter) 그냥 섞여 쓰이는 것 같다.

(1) C++에서는 오버로딩과 오버라이딩이 동시에 같이 자동 지원되지는 않는다.
가령, 한 클래스에서 foo라는 같은 이름으로 void foo()와 void foo(int x)를 모두 정의하는 건 당연히 가능하다(오버로딩).
하지만 둘 중 하나를(가령, 후자) 가상 함수로 지정해서 파생 클래스에서 그놈을 오버라이딩 했다면.. 파생 클래스에서는 오버라이딩 되지 않은 다른 '오버로드' 버전은 자동 지정이 되지 않는다. 이건 혼동을 피하기 위해 C++ 언어의 스펙 차원에서 일부러 그렇게 설계된 것이다.

파생 클래스에서 전자를 사용하려면 기반 클래스의 이름을 거명해서 기반::foo() 이런 식으로 언급하거나..
아니면 파생 클래스의 선언부에서 using 기반::foo; 라고 명시적으로 선언을 해 주면 된다. 그러면 foo만으로 기반 클래스의 두 함수를 곧장 접근할 수 있게 된다. using에 이런 활용 가능성도 있었다니~!

(2) 클래스 객체의 생성 단계에서 멤버들이 초기화되는 순서는 헤더에서 선언된 순서이지, 생성자 함수의 이니셜라이저 목록에 등장하는 순서가 아니다. 그렇기 때문에..

class Bar {
    int x,y;
public:
    Bar(int n): y(n), x(y+1) {}
};

위의 코드에서 x는 제대로 초기화되지 않는다. x(y+1)이 y(n)보다 먼저 실행되기 때문이다.
이건 방대하고 복잡한 클래스를 구현할 때 정말 쉽게 간과하고 실수할 수 있는 특성인데 컴파일러가 경고라도 해 줘야 되지 않나 싶다. 함수 안의 지역변수에서 "초기화되지 않은 변수 거시기를 사용했습니다" 경고를 띄우는 것과 비슷한 로직으로 별로 어렵지 않게 구현할 수 있을 텐데..??

(3) 생성자와 소멸자 함수에서는 자기 객체에 대한 가상 함수를 호출하지 말아야 한다. 비록 기반 클래스의 생성자 및 소멸자가 파생 클래스의 생성자 및 소멸자의 실행 과정에서 같이 호출되겠지만.. 기반 클래스는 자기 객체가 진짜로 기반인지 아니면 상속된 파생 클래스로부터 호출된 것인지를 전혀 알 수 없으며, 알려고 하지도 말아야 한다. 그냥 기반 클래스 자신의 일만 하면 된다.

이는 프로그래머에게 불편을 끼치는 규제가 아니라 언어의 컨셉이 그러하기 때문이다. 생성자와 소멸자는 Windows 메시지로 치면 그냥 WM_CREATE/DESTROY가 아니라 NC 버전이다. (WM_NCDESTROY는 자식 창들이 몽땅 다 사라지고 없는 상태에서 호출) 그리고 복잡한 처리를 절대로 해서는 안 되는 DllMain 함수와도 같다.

기반 생성자는 이 객체가 파생 클래스로서의 초기화가 되기 전에 먼저 호출되고, 기반 소멸자는 파생 클래스 부분이 소멸된 뒤에 맨 나중에 호출된다. 그러니 파생 클래스가 어떠한지를 따지는 것은 전혀 무의미한 것이다.
이건 위의 2번과 마찬가지로 초기화의 순서와 관련해서 저지르기 쉬운 실수이다. 2번은 멤버들의 초기화 순서이고(수평??) 3번은 상속 계층에서의 초기화 순서이다(수직??).

소멸자 자체는 반드시 가상 함수 형태로 선언해야 하지만 소멸자 내부에서 또 다형성을 기대하지는 말아야 한다는 것이 아이러니이다.

(4) 멤버 함수 포인터로 가상 함수의 오버라이딩을 제어할 수는 없다.
한 함수 포인터로 기반 클래스의 함수 내지 파생 클래스의 오버라이딩 함수 중 원하는 것의 주소를 저장하고, ptr의 값(정확히는 ptr이 가리키는 vtbl의 값)과 무관하게 ptr에 대해 원하는 함수를 강제 호출하는 것은 가능하지 않다.

그런 시도를 했을 때 함수 포인터에 저장되는 것은, 그저 ptr->vtbl에 매핑된 버전의 함수를 따로 호출하는 역할을 수행하는 thunk뿐이다.
그러니 멤버 함수 포인터를 동원한다 하더라도 vtbl을 거친 가상 함수 본연의 동작을 변형할 수는 없다.

Posted by 사무엘

2020/07/17 08:35 2020/07/17 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1774

1. 코드의 입체적 배치

C/C++에는 여느 프로그래밍 언어들과 마찬가지로 if else 조건문이란 게 있고 이게 여러 단계로 중첩될 수 있다. 단계가 깊어질수록 코드에서 들여쓰는 왼쪽 여백이 증가한다.

그런데 C/C++에는 전처리기 지시라는 것도 있어서 컴파일되는 실제 코드와는 완전히 별개의 다른 문맥과 차원에서 해석된다. 희곡에서 다른 코드들이 연극 대사라면 전처리기 지시는 괄호 안의 상황 설명지시와 비슷한 존재 같다. 결정적으로는 전처리기에도 조건부 컴파일을 지시하는 #if #else #endif 같은 물건이 있다.

전처리기의 #if도 여러 단계로 중첩되면 알아보기가 상당히 힘들어진다.
그러니 문득 드는 생각은.. 소스 코드의 들여쓰기도 3차원 입체로 표현 가능하면 어떨까 싶다. 통상적인 if else 등의 들여쓰기는 지금처럼 왼쪽 여백으로 표현하고, #if의 단계가 증가하면 그 부분의 코드가 몽땅 X, Y가 아닌 Z축으로.. 전방으로 한 단계 돌출되는 것이다. 해당 부분이 끝나면 다시 쑥 들어가고..
그러고 보니 전처리기 중에는 #if 말고도 #pragma pack처럼 스택 기반으로 설정을 저장하는 것들이 더 있기도 하다.

컴퓨터야 1차원적인 메모리 셀에서 코드와 데이터를 죽어라고 읽고 쓰고 계산하는 기계이겠지만, 그걸 기술하는 프로그램 코드라는 건 색깔(syntax coloring)과 XYZ 축 공간을 모두 이용해서 인간이 최대한 알아보기 편하게 시각화를 할 수 있다. 하지만 이런 수단을 몽땅 동원해도 남이 만든 코드는 선뜻 읽기가 어렵다.;;.

2. 컴파일러의 경고

C/C++ 코딩을 하다 보면 컴파일러가 뱉어 주는 경고 메시지의 도움을 종종 받곤 한다.
제일 자주 보는 건 아무래도 선언만 해 놓고 사용되지 않은 변수, 초기화하지 않고 사용한 변수, void형 함수가 아닌데 return으로 실행이 종료되는 구간 따위이다. 이런 건 에러로 치면 단순 스펠링 오타나 {}() 호응 미스, type mismatch만큼이나 흔하다. 아 하긴 type mismatch는 가벼운 건 warning 형태도 있긴 하다.

경고의 민감도를 상향 조정하면 if문에서 괄호 없이 대입(=) 연산자가 쓰인 것(혹시 비교 연산 ==이랑 헷갈린 거 아니냐?), 우선순위가 아리까리 한 << 나 & 같은 연산자가 괄호 없이 마구 섞여 쓰인 것, 심지어 for이나 if문이 뒷부분 없이 그냥 세미콜론으로 종결된 것까지도 실수가 아닌지 의심스럽다고 일단 지적해 주기도 한다.
글쎄, 컴파일러가 그 정도로 민감하다면.. 본인이 예전에도 언급한 적이 있지만 a=a++이나 a>>-2처럼 이식성이 없는(즉, 컴파일러마다 결과가 다를 수 있는 undefined behavior) 수식이야말로 안 쓰는 게 좋다고 경고를 띄워 줘야 하지 않나 싶다.

요즘 컴파일러는 printf/scanf에서 %문자와 실제 인지의 대응이 맞는지까지도 체크한다. printf 출력일 때는 float건 double이건 %f만 써도 충분하지만(float도 어차피 double로 값이 promote되므로), scanf 입력일 때는 둘은 %f와 %lf로 정확하게 구분돼야 한다.
가변 인자는 그야말로 type-safety의 완벽한 사각지대인데 이런 실수를 컴파일러가 잡아 준다면 프로그래머에게 굉장히 도움이 된다. 원래 전문적인 '정적 분석'용으로 쓰이는 함수의 인자별 annotation 정보까지 컴파일러가 활용하는 것 같다.

그런데 내가 지금까지 본 컴파일러 경고들 중 제일 계륵 같은 건 코드를 32비트에서 64비트로 포팅 하면서 생겨난 수많은.. type mismatch이지 싶다. 이제 int의 크기와 포인터의 크기가 일치하지 않게 되고, 덕분에 int와 INT_PTR의 구분이 생겼기 때문이다.
일단, 이 경고는 레거시 코드에서 발생하는 양이 어마어마하게 많은 편이다. 그리고 (1) 치명적인 것하고 (2) 그다지 치명적이지 않은 것이라는 분명한 구분이 존재한다.

int에다가 포인터를 곧장 대입하는 부분은 전자에 속한다. 이건 번거롭더라도 int를 당연히 INT_PTR로 바꿔 줘야 한다.
그러나 두 포인터의 차이를 대입하는 부분은 상대적으로 덜 치명적인 부분에 속한다. 왜냐 하면 64비트 환경이라 해도 작정하고 프로 연구자가 컴퓨터를 굴리는 게 아닌 한, 단순 end-user급에서 대놓고 2GB, 4GB를 넘는 데이터를 취급할 일은 거의 없다시피하기 때문이다.

특히 문자열의 길이를 구하는 strlen, wcslen 같은 함수 말이다. 리턴 타입이 size_t이지만.. 난 경고를 없애기 위해 그냥 대놓고 #define _strlen32(x) static_cast<int>(strlen(x)) 이런 것도 만들어서 썼다.
주변의 int 변수를 몽땅 확장하기에는 내 함수의 인자와 리턴값, 구조체 멤버 등 영향 받는 게 너무 많고 귀찮고, 그 반면 세상에 문자열 길이가 4GB를 넘어갈 일은 없기 때문이다.

대부분의 경우 무시해도 상관은 없지만 그래도 경고가 뜨는 게 마음에 걸리고, 그렇다고 기계적이고 무의미한 typecast 땜빵을 하고 싶지도 않으니.. 이건 64비트 컴퓨팅이 선사한 계륵 같은 경험이었다.

3. #include 절대경로 표시

요즘 개발툴 IDE, 에디터들은 코드에서 각종 명칭을 마우스로 가리키기만 하면 그게 선언된 곳이 어딘지를 친절하게 알려 준다. #define 매크로도 다 파악해서 이게 전개된 결과가 무엇인지도 툴팁 형태로 표시해 준다.
이와 관련해서 개인적으로는.. #include 다음에 이어지는 토큰을 마우스로 가리키고 있으면 얘가 무슨 파일을 가리키는지도 절대경로를 알려 주는 기능이 있으면 좋겠다. 이 파일을 여는 기능은 Visual Studio건 xcode건 이미 다 제공되고 있으니, 그렇게 알아낸 파일명을 가만히 표시만 해 주면 된다.

C/C++의 include 경로 찾기 규칙은 꽤나 복잡한 구석이 있기 때문이다. #define이 정의돼 있는 줄 모르고 삽질하는 것처럼, 예상하지 않은 다른 디렉터리에 있는 동명의 파일이 잘못 인클루드 되어 착오가 발생할 가능성이 있다.
이는 마치 경로를 생략하고 파일명만 달랑 입력했을 때 실행 파일 디렉터리를 탐색하는 순서라든가 LoadLibrary 함수가 DLL의 경로를 탐색하는 순서와도 비슷한 면모이다.

#include로 지정하는 경로는 C/C++ 문법의 지배를 받는 문자열 리터럴이 아니다. ""로 둘러싸기 때문에 문자열 리터럴처럼 보이지만 <>로 둘러싸는 것도 가능하고.. 여기서는 역슬래시 탈출문자가 적용되지 않기 때문에 디렉터리 구분자를 지정할 때 \를 번거롭게 두 번 쓸 필요도 없다. 애초에 #include는 컴파일러가 전혀 아닌 전처리기의 영역이니 당연한 소리인데.. 가끔은 당연한 사실이 당연하게 와 닿지가 않는다.

그런데 #include 경로명에다가 매크로 상수를 지정할 수는 있다. 내가 지금까지 이런 기괴한 용례를 본 건 지금까지 FreeType 라이브러리의 소스가 유일하다. IDE가 이런 것까지 다~~ 파악해서 실제로 인클루드 되는 헤더 파일을 사용자에게 알려준다면 코드를 분석하는 데 큰 도움이 될 것이다.

4. 리터럴 형태의 표현

프로그래밍 언어가 표현력이 좋으려면, 함수 코드든지 재귀성을 지난 복잡한 데이터든지(리스트, 배열 테이블 등) 불문하고 하나의 리터럴 내지 값(value)로 취급 가능하며, 굳이 이름을 붙여서 변수로 할당하지 않아도 함수 인자나 리턴값으로 자유자재로 주고 받고 대입 가능해야 한다.
쉽게 말해 함수 포인터가 들어갈 곳에 이렇게 함수 몸체가 곧장 들어갈 수 있어야 하며..

qsort(pData, nElem, nSize, [](const void *a, const void *b) { return 어쩌구저쩌구 } );

데이터가 들어갈 곳에도 이렇게 배열을 즉석에서 지정할 수 있어야 한다는 얘기이다.

memcpy(prime_tbl, {2,3,5,7, 11}, sizeof(int)*5);

하지만 C/C++은 이런 쪽의 유연한 표현력이 매우 취약했다.
함수 쪽은 machine word 하나에 딱 대응하는 것 이상의 context를 담은 추상적인 포인터를 지원하지 않는 관계로 클로저나 함수 안의 함수 따위를 지원하지 않는다.

그리고 언어 차원에서 복합 자료형을 built-in type으로 직접 지원하는 게 없다. 전부 프로그래머나 라이브러리의 구현에 의존하지..
그렇기 때문에 복잡한 데이터 리터럴은 변수를 초기화하는 이니셜라이저 형태로나 아주 제한적으로 표현 가능하며 이마저도 구조체· 배열의 초기화만으로 한정이다. 리터럴 형태 표현 가능한 배열 비스무리한 건 읽기 전용 null-terminated 문자열이 고작이다.

함수를 리터럴 형태로 표현하는 건 C++에 람다와 함수형 패러다임이 도입되면서 상황이 많이 나아졌다.
그 뒤 복합 자료형을 리터럴 형태로 표현하는 것도 C++1x 이후로 하루가 다르게 새로운 문법이 도입되면서 바뀌고 있긴 하다.

이름을 일일이 붙이지 않고 아무 테이블 및 계층 자료구조, 그리고 함수를 마음대로 선언해서 함수의 인자나 리턴값으로 주고받을 수 있는 것은..
마치 메신저나 이메일로 스크린샷 그림을 주고받을 때 매번 그림을 파일로 저장하고 그 파일을 선택하는 게 아니라 간단히 print screen + 클립보드 붙여넣기만으로 그림 첨부가 되는 것과 비슷하다. 의식의 흐름을 매우 편리하고 직관적으로 코딩으로 표현 가능하게 해 준다.

디자인 근본적인 차이로 인해 C++이 무슨 파이썬 수준의 유연함을 갖는 건 무리이겠지만 저 정도만으로도 엄청난 변화이며 한편으로 컴파일러를 구현하기에는 굉장히 난감할 것이다. 저수준 성능과 고수준 추상화 범용성이라는 두 모순되는 토끼를 몽땅 잡아야 하기 때문이다. 특히 그런 문법이 템플릿 내지 캐사기 auto와 결합하면.. 복잡도가 끔찍할 수준일 것 같다.

5. 기타

(1) 변수나 함수를 선언할 때는 type을 지정하면서 각종 modifier나 storage class를 같이 써 주게 된다. 거기에 들어가는 단어 중에는 static과 const, 그리고 int와 __declspec(..)처럼 대충 순서가 바뀌어도 되는 것이 있다.
그런데 long unsigned a도 된다는 것은 지난 20여 년 동안 본인이 한 번도 시도해 본 적이 없었다. 영어 어순 직관과 어울리지 않으니까.. 하긴, 2[a]도 되는 언어에서 저 정도쯤이야 이상할 게 없다.

(2) void main(void) {}은 컴파일은 되지만 void가 뭔가 권장되지 않고 바람직하지 않은 형태로 쓰이는 전형적인 예시라 하겠다. main 함수의 프로토타입도 그렇고, 또 함수에 인자가 없음을 나타낼 때는 C/C++ 가리지 않고 ()만 써도 충분하기 때문이다.

(3) 잘 실감이 나지 않겠지만 요즘은 C와 C++은 서로 따로 제 갈 길 가고 있다. 특히 C99와 C++1x부터 말이다. 그렇기 때문에 세월이 흐를수록 C 코드를 C++ 컴파일러에서 곧바로 돌리기는 어려워질 것이다.

보통은 C가 C++에 있던 // 주석, inline 키워드, C++ 라이브러리에 있는 몇몇 기능들을 자기 스타일로 도입하는 형태였지만 최신 C에서 C++과 무관하게 독자적으로 도입한 기능 중 하나는 restrict 키워드이다. 얘가 가리키는 메모리 주소는 딴 데서 건드리지 않으니 마음 놓고 최적화해도 된다는 일종의 힌트이다. volatile과는 반대 의미인 듯하다.

컴파일러에 따라서는 C++에서도 얘를 __restrict 이런 형태의 비표준 확장으로 도입한 경우가 있다. 하지만 Visual C++은 내가 알기로는 그리하지 않은 것 같다. 마소 컴파일러는 C 단독은 거의 없는 자식 취급하고 C99 지원도 안 하고 있으니 말이다.

(4) 방대한 C/C++ 코드에 정적 분석을 돌려 보면, 아무 type-safety 단서 없이 무데뽀로 아무 메모리에다 임의의 바이트만치 쓰기를 허용하는 memset, memcpy 계열의 함수에 실수와 버그가 들어간 경우가 생각보다 굉장히 많다고 한다.
배열 크기만큼 써야 하는데 포인터 크기(4/8바이트!)만치만 기록해 버리는 건 약과다. 둘째 인자와 셋째 인자의 순서가 바뀌어서 0을 기록하는 게 아니라 0바이트만치만 기록한다거나..;;

sizeof(A)*b라고 써야 할 것을 실수로 sizeof(A*b)라고 써 버려서 역시 4/8바이트 고정과 같은 효과가 나기도 한다. 전체 바이트 수를 써야 하는 곳과 배열의 원소 수만 써야 하는 곳을 헷갈리는 것도 미터나 피트 같은 단위를 헷갈려서 착오를 일으킨 것과 비슷하다.
문제는 저런 건 잘못 써도 언어 문법상으로는 아무 잘못이 없고 철저하게 합법이라는 것이다. 컴파일러가 잡아 주지 못하니 더 고차원적으로 문맥을 읽는 정적 분석에 의존해야 한다.

Posted by 사무엘

2020/04/17 08:36 2020/04/17 08:36
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1741

오늘날 마이크로소프트는 운영체제와 오피스뿐만 아니라 개발툴 분야도 세계를 석권해 있다.
걔들은 과거에 운영체제 쪽은 맥 내지 IBM OS/2와 경쟁했었고, 오피스는 로터스, 워드퍼펙, 한컴(...)과 경쟁했으며.. 개발툴 쪽은 볼랜드라는 쟁쟁한 기업과 경쟁했다.

마소와 볼랜드가 내놓았던 프로그램 개발툴은.. 먼저

1. IDE까지 있는 도스용 대중 보급형의 브랜드가 있었다.
볼랜드는 터보, 마소는 퀵.. 뭔가 스피디한 단어를 썼다는 공통점이 있다.
그리고 볼랜드는 브랜드명-언어명 사이를 띄었지만, 마소는 둘을 붙여 썼다.;;

Turbo Basic, Turbo C, Turbo Pascal
QuickBasic, QuickC, QuickPascal

다음은 볼랜드 말고 '마소'에서 개발했던 QuickC와 QuickPascal IDE의 스크린샷이다. 보기에 참 생소하다. 출처는 유명한 고전 소프트웨어 라이브러리인 WinWorld이다.

사용자 삽입 이미지

사용자 삽입 이미지

마소는 QuickBasic만 건지고 나머지는 다 망했다. QuickBasic이야.. 뭐 무료 축소판 QBasic을 MS-DOS와 Windows에다 포함시키기까지 했을 정도이고 말이다. 빌 게이츠가 베이식 언어를 아주 좋아했다.
그 반면 볼랜드는 Turbo Basic만 망하고 C와 Pascal을 건졌다. Turbo Basic의 개발진은 볼랜드를 퇴사하고 따로 회사를 차려 PowerBasic을 만들게 됐다.

2. 다음으로, 본가에 속하는 최상위 플래그십 제품군에는 그냥 자기 회사명을 붙였다.

Borland Pascal, C++
Microsoft Basic, C/C++

1990년대에 C에 이어 C++ 컴파일러가 개발되면서 자기 제품의 공식 명칭을 아예 C++이라고 바꿔 붙이는 곳이 있는가 하면, C와 겸용임을 내세우면서 C/C++이라고 붙이는 곳도 있었다.

볼랜드의 경우 C++을 C와는 완전 별개로 취급했는지 버전까지 1.0으로 도로 리셋하면서 Turbo C++ 내지 Borland C++이라고 작명했지만.. 마소는 C++을 기존 C 컴파일러의 연장선으로 보고 MS C 6.0 다음으로 7.0을 MS C/C++ 7.0이라고 작명했다. 사실, 연장선이라고 보는 게 더 일반적인 관행이었다.

참고로 왓콤 역시 Watcom C 9.0의 다음 버전이 Watcom C/C++ 9.5가 돼서 마소와 비슷하게 작명과 버전 넘버링을 했다. 왓콤은 제품이 짬이 길다는 인상을 주기 위해 첫 버전을 일부러 1이 아닌 6.0부터 시작하는 기행을 벌였었다! 볼랜드의 버전 넘버링과 비교하면 극과 극 그 자체였다.

터보 C++이랑 볼랜드 C++의 차이는.. 더 덩치 큰 상업용 프로그램 개발을 위한 OWL/Turbo Vision 같은 자체 프레임워크 라이브러리를 제공하느냐 여부 정도였지 싶다. 프로페셔널 에디션이냐 엔터프라이즈 에디션이냐의 차이처럼 말이다. 그리고 이때쯤 Windows용 지원도 시작됐다.

3. 그랬는데, 1990년대 이후부터는 그 플래그십 제품군도 Windows 전용의 더 고급 브랜드로 대체됐다.

볼랜드는 90년대 중반의 Delphi와 C++Builder로,
마소는 그 이름도 유명한 비주얼 브랜드로 말이다. Visual Basic, Visual C++.
그리고 마소도 Visual C++부터는 C/C++ 대신 C++만 내걸기 시작했으며,

관계가 이렇게 된다.
Visual C++이 과거 MS C/C++을 계승한 거라는 흔적은 _MSC_VER 매크로 값이 Visual Studio 자체의 버전보다 더 크다는 점을 통해서나 유추할 수 있다.

1이 2를 거쳐 3으로 바뀌는 동안 주변에서는 C 대신 C++이 대세가 되고, 주류 운영체제가 도스에서 Windows로 완전히 넘어가고 거대한 프레임워크 라이브러리가 등장하는 등의 큰 변화가 있었다. 개발 환경도 단순히 코딩용 텍스트 에디터와 디버거 수준을 넘어서 RAD까지 추구하는 수준으로 발전했다.

또한, 이 3단계가 주류가 될 즈음부터 마소의 Visual 툴들이 볼랜드를 완전히 꺾고 제압해 버렸다.
마소가 운영체제 홈그라운드라는 이점을 갖고 있기도 했거니와, 또 근본적으로는 파스칼이라는 언어 자체가 볼랜드의 창업자인 필립 칸이 선호하거나 예상한 것만치 프로그래밍계의 주류가 되지 못하고 마이너로 밀려난 것이 크게 작용했다. 네이티브 코드 생성이 가능하면서 빌드 속도가 왕창 빠른 건 개인적으로 무척 마음에 들었는데 말이다..;;

그에 반해 마소의 베이식은 파스칼보다 그리 나은 구석이 없는 언어임에도 불구하고 자사 운영체제의 닷넷빨 있지, 레거시 베이식도 자사 오피스의 VBA 매크로 언어가 있으니 망할 일이 없는 지위에 올라 있다.

한때(1990년대 후반??)는 파스칼이 언어 구조가 더 깔끔하고 좋다면서 정보 올림피아드 같은 데서라도 각광 받았지만.. 지금은 그런 것도 없다. 그 바닥조차도 닥치고 그냥 C/C++이다.
델파이를 기반으로 이미 만들어진 유틸리티나 각종 DB 연계 프로그램들(상점 매출 관리 등등..), SI 쪽 솔루션을 제외하면 파스칼은 마치 아래아한글만큼이나 입지가 좁아져 있지 않나 싶다..;;.

범언어적인 통합 개발 환경이라는 개념을 내놓은 것도 마소가 더 일렀다. Visual Studio가 나온 게 무려 1997년이니까.. 개발툴계의 '오피스'인 셈이다. (Word, Excel 등 통합처럼 Basic, C++ 통합). 그에 비해 볼랜드 진영에서 Delphi와 C++Builder를 통합한 RAD Studio를 내놓은 것은 그보다는 훨씬 나중의 일이다.

Windows NT야 이미 있던 16비트 Windows와 버전을 맞추기 위해서 3.1부터 시작했는데, Visual Studio의 경우, 공교롭게도 1990년대 중반까지 Visual Basic과 Visual C++의 버전이 모두 4.x대였다.
그래서 첫 버전인 Visual Studio 97은 각각의 툴 버전과 Studio 버전이 모두 깔끔하게 5로 맞춰졌으며, 이듬해에 나온 차기 버전은 어째 98이라는 연도 대신, 버전인 6으로 맞춰질 수 있었다.

2010년대 이후로 C++이 워낙 미친 듯이 바뀌고 발전하고 있으니.. D 같은 동급 경쟁 언어들조차 기세가 꺾이고 버로우 타는 중이다. 도대체 지난 2000년대에 C++98, C++03 시절에는 C++ 진영이 export 병크 삽질이나 벌이면서 왜 그렇게 침체돼 있었나 의아할 정도이다. 그 사이에 Java나 C# 같은 가상 머신 기반 언어들이 약진하니, 뭘 모르는 사람들은 겁도 없이 "C++은 이제 죽었네" 같은 소리를 태연히 늘어놓을 지경까지 갔었다. (2000년대 중반이 Windows XP에, IE6에... PC계가 전반적으로 좀 '고인물'스러운 분위기로 흘러가던 때였음) 한때 잠시 그러던 시절이 있었다.

Posted by 사무엘

2020/01/20 08:34 2020/01/20 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1707

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

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

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

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

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

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

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

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

Tue Nov 18 13:57:11 2014

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class A;
class A::B;

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

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

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

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

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

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

3. 함수: extern "C"

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

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

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

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

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

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

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

Posted by 사무엘

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

1. 문법 함정

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4. 쓰레기값

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

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

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

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

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

5. 메모리 주소의 align 문제

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

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

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

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

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

6. 32비트 단위 문자열

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

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

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

7. 레퍼런스 사이트

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

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

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

Posted by 사무엘

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

« Previous : 1 : 2 : 3 : 4 : 5 : ... 8 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/09   »
      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:
1652272
Today:
111
Yesterday:
436