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

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

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

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

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

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

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

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

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

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

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

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

main() { return 0; }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4. macOS와 Windows의 관점 차이

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

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

1. static_cast

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

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

2. const_cast

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

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

3. reinterpret_cast

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

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

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

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

4. dynamic_cast

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

C++ 다중 상속 생각

날개셋 한글 입력기 같은 Windows용 프로그램을 개발하다 보면 여러 개의 COM 인터페이스를 한꺼번에 상속받아 구현한 단일 클래스를 구현하게 된다.

그런데 하루는 이런 의문이 들었다. 각각의 인터페이스들이 다 IUnknown을 상속받았는데 어떻게 어느 인터페이스로 접근하든지 AddRef, Release 같은 공통 인터페이스들은 중복 없이 동일한 함수 및 동일한 숫자 카운터 인스턴스로 연결될까? 데이터 멤버 없이 인터페이스 상속만 하면 this 포인터 보정이 필요 없이 다중 상속과 관련된 문제들이 상당수 깔끔하게 해결될까?

그래서 클래스 A, 이로부터 상속받은 B와 C, 그리고 B와 C를 다중 상속한 D 이렇게 네 개의 클래스가 있을 때 일명 ‘죽음의 다이아몬드’ 현상을 해소하는 방법이 무엇이 있는지를 정리해 봤다. C++의 다중 상속과 관련해서는 이제 더 글을 쓸 게 없을 줄 알았는데 내가 지금까지 생각하지 못하고 있던 요소들이 더 있었다.

1. 가상 상속

클래스에서 상속이라는 건 기술적으로 어떤 구조체에다가 부모 클래스의 컨텐츠(데이터 멤버)들을 앞에 쭉 늘어놓고 나서 그 뒤에 나 자신의 컨텐츠를 추가하는 것과 같다. 그러니 부모 클래스와 자식 클래스 포인터를 형변환 하는 건 그냥 프로그래밍 언어 차원에서의 의미 변환일 뿐, 메모리 주소가 바뀌는 것은 전혀 없다. 아주 쉽다.

그런데 가상 상속은 부모 클래스의 컨텐츠를 그렇게 나 자신의 일부로서 고정된 영역에 배치하는 게 아니라, 포인터로 참조하는 것과 같다.
부모 클래스를 ‘가상’이라는 방식으로 상속한 자식 클래스는 부모 클래스와 자식 클래스가 굳이 메모리 상에 연속된 형태로 있지 않아도 된다. 그러니 동일 부모를 공유하는 다수의 클래스가 다중 상속되더라도 이들이 공통의 유일한 부모 하나만을 가리키게 하면, 한 부모 클래스의 데이터들이 불필요하게 여러 번 상속되는 것을 막을 수 있다.

여기서 중요한 것은, D가 B, C를 ‘가상’ 상속하는 게 아니라는 점이다. 부모인 B와 C가 A를 미리 가상으로 상속해 놔야 한다.
가상 함수도 자식이 아닌 부모 클래스에서 미리 지정해 놔야 하듯 말이다.

그러니 클래스 라이브러리 개발자는 공통 부모를 공유하는 여러 클래스들이 사용자에 의해 다중 상속되겠다 싶으면 그 공통 부모를 virtual로 상속하도록 설계를 미리 해 놔야 한다. 특히 그 클래스(공통 부모 말고)가 순수 가상 함수 같은 걸 포함하고 있어서 상속이 100% 필수라면 더욱 그러하다.
앞의 A~D의 경우, 혹시 A가 default constructor가 없어서 B, C의 생성자에 모두 A를 초기화하는 인자가 들어있었다 하더라도, D의 A는 D의 생성자에서 제공된 인자만으로 딱 한 번만 초기화된다.

가상 상속을 한 자식 클래스는 굉장히 이색적인 특징을 하나 갖게 된다.
자식 클래스의 포인터에서 부모 클래스의 포인터로 형변환을 하는 것이야 너무 당연한 귀결이며, 반대로 부모에서 자식으로 가는 건 좀 위험한 일이긴 하지만 어쨌든 가능하다. 단일 상속에서는 말할 필요도 없고, 다중 상속이라 하더라도 그냥 고정된 크기만큼의 포인터 덧셈/뺄셈만 하면 된다.
그에 반해, 부모 클래스에서 자신을 virtual 상속한 자식 클래스로 형변환은 일반적으로 허용되지 않는다…!

A *pa = new D; //자식에서 부모로 가는 건 당연히 되고
B *pb = new D;
D *pd;
pd = static_cast<D*>(pb); //부모에서 자식으로 가는 건 요건 괜찮지만
pd = static_cast<D*>(pa); //요건 안 된다는 뜻..

그 자식 클래스의 주소와 부모 클래스의 주소 사이에는 컴파일 타임 때 결정되는 관계 내지 개연성이 없기 때문이다.
자식에서 부모로 거슬러 올라가는 게 단방향 연결 리스트를 타는 것과 다를 바 없게 됐는데, 저런 형변환은 단방향 연결 리스트를 역추적하는 것과 같으니까 말이다.

물론, 가상 상속이라 해도 현실에서는 D라는 오브젝트 내부에서 A가 배치되는 오프셋은 고정불변일 것이고 컴파일러가 그 값을 계산하는 게 불가능하지 않을 것이다. 모든 자식 클래스들과 연속적으로 배치되지만 않을 뿐이다.
static_cast를 어거지로 구현하라면 구현할 수는 있다. 하지만 이 A가 반드시 D에 속한 A라는 보장도 없고, 포인터에 무엇이 들어있는지 확신할 수 없는데.. C++ 컴파일러가 그런 어거지 무리수까지 구현하지는 않기로 한 모양이다.

2. 가상 함수로 이뤄진 추상 클래스(인터페이스)들만 상속

죽음의 다이아몬드를 해소하기 위해서 요즘 프로그래밍 언어들은 C++ 같은 우악스러운 수준의 다중 상속을 허용하지 않고, 잘 알다시피 데이터 멤버 없고 가상 함수로만 구성된 추상 클래스들의 다중 상속만 허용하곤 한다.
그러면 문제의 복잡도가 크게 줄어들긴 한다. 효과가 있다. 하지만 그게 전부, 장땡은 아니다.

명시적인 데이터가 없는 클래스라 하더라도 가상 함수가 들어있는 클래스를 상속받을 경우, 2개째와 그 이후부터는 클래스 하나당 vtbl (가상 함수 테이블 v-table) 포인터만치 클래스의 덩치가 커지게 된다.

단일 상속 체계에서는 this 포인터의 변화가 전무하니 상속을 제아무리 많이 하더라도 한 vtbl의 크기만 커질 뿐, 그 테이블을 가리키는 포인터의 개수 자체가 늘어날 필요는 없다.
그러나 다중 상속에서는 D 같은 한 객체가 상황에 따라 클래스 B 행세도 하고 클래스 C 행세도 하면서 카멜레온처럼 변할 수 있어야 한다. 그렇기 때문에 A, B, D일 때의 vtbl, 그리고 C일 때의 vtbl 이렇게, 테이블과 테이블 포인터가 둘 필요하다.

클래스 D에 속하는 인스턴스 포인터(가령, D *pd)를 부모 C의 포인터로 변환해서 전달할 때는 pd는 A, B, D 같은 직통 상속 계열 vtbl이 아니라 C의 vtbl을 가리키는 형태로 오프셋이 보정된다. 그리고 여기서 가상 함수를 호출하면.. this 포인터가 C가 아닌 D를 기준으로, 보정 전의 형태로 복구된 채로 함수에 전해진다. 이 함수는 애초부터 C가 아닌 D에 소속된 함수이기 때문이다.

즉, 다중 상속에서 가상 함수를 호출하면 비록 겉으로 this 포인터는 바뀐 게 없지만 내부적으로 vtbl을 찾는 것을 부모와 자식 클래스가 완전히 동일하게 수행하기 위해서 보정이 일어나고, 그걸 함수에다 호출할 때는 보정 전의 값을 전하도록 일종의 thunk 함수가 먼저 수행된다.
한 클래스 오브젝트에서 여러 인터페이스 함수를 자유자재로 호출하는 polymorphism의 이면에는 이런 비용 오버헤드가 존재하는 셈이다. 무슨 숫자나 문자열로 메시지를 전하는 게 아닌 이상, 서로 다른 클래스에 존재하는 가상 함수는 vtbl의 종류와 오프셋으로 구분할 수밖에 없다.

3. 멤버로만 갖기

다중 상속의 지저분함을 회피하는 방법 중 하나는.. 원하는 기능이 들어있는 클래스를 내 아래로 상속하지 말고 그냥 멤버 변수로 갖는 것이다. 상속하더라도 걔만 따로 상속해서 확장 구현을 한 뒤에 그걸 멤버 변수로 갖는다. 이 개념을 유식한 용어로는 aggregation이라고 한다.

이 방법은 다중 상속의 각종 오버헤드는 피할 수 있지만 그만큼 다른 방면에서 불편을 야기한다. 그 클래스가 동작하는 과정에서 내 클래스의 함수 및 데이터를 빈번하게 참조해야 한다면(결합도 coupling가 높은 관계..) 그 통로를 억지로 트는 게 더 불편하며 코드를 지저분하게 만든다. 또한 서로 다른 클래스 간에 중복 없이 동일한 기능을 제공하는 일관된 인터페이스를 만드는 게 다중 상속이 아니면 답이 없는 경우도 있다.

이게 경험상 딱 떨어지는 답이 있는 문제가 아니다. 복잡한 클래스 계층이 필요한 대규모 개발을 한 경험이 없는 프로그래머라면 이런 부류의 문제는 배경을 이해하는 것조차 난감할 것이다. 그렇기 때문에 다중 상속이 무조건 나쁘기만 한 건 아니며, 그걸 억지로 우회하다 보면 결국 다른 형태로 불편함과 성능 오버헤드가 야기될 거라며 다중 상속을 옹호하는 프로그래머도 있다.

이상.
객체지향 프로그래밍 언어에서 다중 상속은 사람마다 취향 논란이 많은 주제이다. 비록 C++이 이걸 지원하는 유일한 언어는 아니지만, 네이티브 코드 생성이 가능한 유명한 언어 중에서는 C++이 사실상 대표격인 것처럼 취급받고 있다.

어떤 기능이 절대적으로 나쁜 것만 아니다면야 없는 것보다는 있는 게 좋을 것이다. 상술했다시피 다중 상속이 가능해서 아주 편리한 경우도 물론 있다. 한 오브젝트로 다수 개의 기반 클래스 행세를 자동으로 하는 것과, 그 오브젝트 내부의 구현 함수에서는 여러 기반 클래스를 넘나드는 게 동시에 되니까 말이다.

하지만 다중 상속은 가성비를 따져 보니 그 부작용과 오버헤드, 삽질을 감수하면서까지 굳이 구현하고 지원할 필요가 있나 하는 게 PL계의 다수설 대세로 흐르고 있다. this 포인터의 보정이라든가, 복수 개의 기반 클래스들이 또 공통의 기반 클래스를 갖고 있을 때 발생하는 모호성의 처리 등.. 템플릿 export만치 막장은 아니지만 컴파일러 개발자와 PL 연구자들의 고개는 설레설레 저어지곤 했다.

그래서 C++ 이후에 등장한 더 깔끔한 언어인 D, C#, Java 등은 다중 상속을 지원하지 않는다. 그 대신 다중 상속을 우회하고 복잡도를 완화하기 위해, 적어도 가상 상속만은 할 필요가 없게끔 static 내지 가상 함수 선언만 잔뜩 들어있는 인터페이스에 대해서만 다중 상속을 허용하는 것이다. Java는 두 종류의 상속을 extends와 implements라고 아예 구분까지 했다.

물론 이런 패러다임 하에서는.. 프로그램 구조가 간단해서 가상 함수로 만들 필요가 없는 것까지 일단은 인터페이스부터 만들어 놓고 구현 클래스를 내부적으로 또 만드는 식의 오버헤드 정도는 감수해야 한다. 하지만 Java는 final이 아닌 함수는 기본적으로 몽땅 가상 함수일 정도로.. 디자인 이념이 애초에 성능 대신 극도의 유연성이니, 그 관점에서는 그건 별 상관이 없는가 보다.

가장 대중적인 기술이 알고 보면 레거시 때문에 굉장히 지저분하고 기괴하기도 하다는 것은 CPU계에서 x86이 그 예이고, 프로그래밍 언어에서는 C++이 해당되지 싶다. 전처리기(+생짜 파일 기반 인클루드), 다중 상속, 클로저 없이 특유의 pointer-to-member 기능 같은 것 말이다. 후대 언어에서는 저런 게 결코 도입되지 않고 있다.

본인은 다중 상속을 굳이 의도적으로 기피하면서 코딩을 하지는 않는다. 다중 상속이 필요하고 당장 편하겠다 싶으면 한 클래스에다 막 엮었다. 그 상태로 막 복잡한 pointer-to-member나 람다를 구사하면서 컴파일러를 변태적으로 괴롭히지는 않을 것이고, 컴파일러가 그냥 this 포인터 보정을 알아서 해 주는 것만 원했으니까 말이다.

하루는 ", public"라고 검색을 해서 날개셋 한글 입력기의 소스 코드 내부에도 혹시 다중 상속을 쓴 부분이 있나 찾아 봤는데.. 그래도 2017년 현재 날개셋 한글 입력기의 소스 코드에는 데이터 멤버가 존재하는 클래스를 둘 이상 동시에 상속받은 부분은 없었다. 추가적인 상속처럼 보이는 것은 COM 인터페이스 내지, 내가 콜백 함수를 대신해서 내부적으로 만들어 놓은 추상 클래스 인터페이스들이었다.

한두 번 썼을 법도 해 보이는데.. 이 정도 규모의 프로그램을 만드는 데도 실질적인 다중 상속을 사용한 부분이 없다면 그건.. 정말로 가성비 대비 불필요하게 지원할 필요는 없을 법도 해 보인다.

Posted by 사무엘

2017/12/26 08:37 2017/12/26 08:37
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1441

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

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

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

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

B *ptr = new B;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4. 맥용 swscanf의 꼬장

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

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

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

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

5. 정적 분석 써 본 소감

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

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

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

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

6. C++ 디버깅

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

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

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

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

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

Posted by 사무엘

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

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

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

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

2. CPU 사용 없는 무한루프

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

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

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

4. stack overflow와 함께 뻗음

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

2. trigraph

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

struct A { int a,b; };

struct B { int c,d; };

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

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

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

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

Posted by 사무엘

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

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

Site Stats

Total hits:
2634519
Today:
1317
Yesterday:
1754