1. ? :에서 피연산자의 타입 동기화 방식

C/C++에서 포인터는 컴퓨터가 내부적으로 메모리를 다루는 메커니즘을 아무 보정 오버헤드 없이 쌩으로 노출하고 관리를 프로그래머에게 전적으로 맡기는 물건이다. 그러니 강력한 대신 매우 위험하기도 하며, 사용자의 실수가 들어가기 쉽다.

이런 한계를 극복하기 위해 C++에는 생성자와 소멸자, 템플릿, 연산자 오버로딩을 적극 활용하여 다양한 형태로 포인터를 컴파일 시점에서 자동 관리해 주는 클래스가 존재한다. 소멸자에서 자신에 대한 delete 내지 Release를 자동으로 클래스가 있으면 한결 편할 것이다. 대입도 기존 오브젝트가 없어지고 다른 걸로 대체되는 거나 마찬가지이니, 레퍼런스 카운팅 관리 같은 걸 해 주고 말이다.

함수가 실행이 실패해서 도중에 return을 해야 하는데 지금까지 할당했던 자원(메모리, 파일)들을 반환은 해야 하니.. 부득이하게 goto문을 쓰느라 코드가 지저분해지는 거 공감하실 것이다. 이런 간단한 것 하나만 생각해도 C++이 C에 비해 코딩을 얼마나 더 편리하게 해 주는지 알 수 있다.

본인은 날포인터를 써서 만들어졌던 옛날 코드를 그런 wrapper 클래스 형태로 리팩터링 했다. 가령, FOO *p = .... p->Release() 하던 것을 CAutoPtr<FOO> p 하나로 대체하는 식이다. 자원을 수동으로 해제하는 코드를 최대한 줄였다.

그런데 하루는 큰 문제 없이 이렇게 고쳐지고 컴파일 됐던 프로그램이 도저히 이해되지 않는 부분에서 뻗는 걸 발견했다.
한참을 디버깅한 끝에 알고 보니... 문제는 A ? B: C 연산자 안이었다. 원래 B와 C 모두 FOO* 타입인데, B만 CAutoPtr<FOO>로 바뀌었던 것이다. 다른쪽 C는 구조체의 멤버이다 보니 타입을 고칠 수 없었고 말이다.

내가 의도한 건 B가 operator FOO*()를 통해 FOO*로 암묵적으로 형변환되는 것이었다. 이 ? : 식은 함수의 인자로 전달되는 문맥에서 쓰였으며, 이 인자의 타입도 그냥 FOO*였다.
그러나 이때 B와 C의 타입을 동기화하기 위해 컴파일러가 한 일은.. CAutoPtr<FOO>(C), 다시 말해 C를 CAutoPtr로 승격시키고 임시 객체를 생성하는 것이었다. 그러고 나서는 그 CAutoPtr에 대해서 역으로 operator FOO*()를 호출하여 리턴값을 함수에다 전달했다.

이 클래스는 생성자에서는 딱히 하는 일 없이 인자로 주어진 메모리 주소를 대입만 하고, 소멸자에서 그 주소가 가리키는 영역을 해제했다.
그러니 임시 객체는 소멸자에서 멀쩡한 메모리를 예기치 않게 해제했으며, 이 부작용 때문에 프로그램이 죽은 것이었다. 아하, 이런 내막이 있었다니... 무릎을 쳤다.

그런데 이 문제를 깔끔하게 해결할 방법은 없는지 본인의 C++ 지식 범위에서는 답이 떠오르지 않는다. 이때는 부득이하게, B에다가 static_cast, (FOO*), operator FOO*() 같은 명시적 형변환을 지저분하게 집어넣어 줘야만 하는 걸까? (리팩터링 전에 날포인터만 쓰던 시절에는 할 필요 없던..)

아니면 CAutoPtr의 생성자를 어째 잘 만들어서 저런 형변환을 허용하지 않고 최소한 에러로 처리시킬 방법이라도 없나 궁금하다. 암시적인 R-value 임시 객체가 생기는 것만 금지하고 막으면 될 거 같은데..??
explicit을 지정하는 것만으로는 충분치 않고, 복사 생성자나 R-value 생성자 같은 걸 어설프게 건드리면 정상적인 객체 생성에 대해서도 에러가 발생하게 되더라.

FOO*를 받아들이는 상황에서도 컴파일러가 B와 C를 모두 일단 클래스로 만든 뒤에 다시 operator FOO*를 호출하는 것은 일종의 언어 차원에서의 디자인 원칙인 것 같다. C++이 함수 오버로딩도 인자의 개수와 타입만으로 판단하지, 리턴값의 타입은 전혀 감안하지 않는 것처럼 말이다. 일을 단순하게 만들기 위해 수식 내부의 토큰을 해석하는 데 수식 바깥 전체의 타입을 굳이 고려하지는 않기로 한 듯하다.

또한, template<T> void Foo(T, T) 이런 함수를 선언한 뒤, 템플릿 인자 없이 함수의 두 인자에다가 CAutoPtr<FOO>와 FOO*를 집어넣는 것은 통하지 않더라. 컴파일러가 어설프게 타입 유추와 동기화를 시도하지 않고 깔끔하게 에러를 내뱉었다. Foo<FOO*> 이렇게 T가 무엇인지를 명시적으로 써 줘야 했다. ? :와는 다른 동작으로 보인다.

? : 연산자에 대해서 본인은 먼 옛날에 대입 연산과 관련된 파싱 방식이 이해되지 않는 게 있어서 글을 쓴 적이 있는데.. 이번엔 다른 분야에서 알쏭달쏭한 게 생겼다. 흥미롭다.

A ? B:C에서 둘 중 하나가 기반 클래스이고 다른 하나가 파생 클래스라면, 이 수식의 결과값이 지칭하는 타입은 B와 C 어느 것이 걸리건 무관하게 당연히 더 범용적인 기반 클래스로 결정된다. 그런데 이것도 다중· 가상 상속이 개입하면 굉장히 골치아픈 문제가 될 것 같다. 파생 클래스가 자신의 실질적인 기반 클래스로 돌아가는 게 trivial한 일이 아니게 되기 때문이다.

2. 클래스 static 멤버 함수에서 non-static 멤버의 sizeof 구하기

C++에서 클래스의 static 멤버 함수는 그 정의상 this 포인터를 갖고 있지 않다. 명칭의 scope resolution만 빼면 기술적으로 일반 global 함수와 전혀 다를 바 없다. 그렇기 때문에 이런 함수의 내부에서 클래스의 non-static 멤버는 당연히 참조할 수 없다.

그런데 sizeof 연산자는 어떨까? 얘는 런타임 때의 메모리 값을 전혀 참조하지 않고, 컴파일 타임 때 결정되는 타입만을 기반으로 답을 구해 주는 답정너 연산자이다. 그러니 this 같은 게 전혀 필요하지 않다. 그럼에도 불구하고 아래의 코드는 옛날 컴파일러에서는 에러가 발생하며 컴파일 되지 않는다. (VC++ 기준 C2070 Illegal sizeof operand)

class Sample {
    int MEMB[4]; //일반 타입이건 배열이건 포인터건 모두 무관
public:
    static void Talk() {
        printf("%d\n", sizeof(MEMB));
    }
};

저 안에서 MEMB의 크기를 어떻게든 구하려면?
sizeof( ((Sample*)NULL)->MEMB) 라고 써 줘야 했다. 마치 구조체 내부에서 특정 멤버의 오프셋을 구할 때처럼.. Sample의 포인터를 야메로라도 만들어야 한 것이다.
sizeof의 피연산자는 실제로 실행되지는 않으니 저런다고 프로그램이 뻗지는 않는다. 하지만 미관상 깔끔하지 못하고 부자연스러운 건 어쩔 수 없다.

그런데 2015쯤 Visual C++ 후대 버전에는 sizeof(MEMB)라고 직통으로 요청하는 게 가능해졌다. 그래, sizeof 정도는 static 함수에서라도 non-static 멤버를 피연산자로 삼을 수 있는 게 이치에 맞다.
클래스 밖에서 sizeof(Sample::MEMB)라고 요청해도 된다. 다만, 위의 코드에서는 MEMB가 비공개 멤버이기 때문에 클래스 밖에서는 컴파일 에러가 나게 된다.

흥미로운 점은, VC++ 2010/2012의 경우 빌드용 메인 컴파일러와 인텔리센스용 컴파일러의 동작이 서로 다르다는 것이다.
전자는 저 문법을 지원하지 않고 에러 처리하지만, 인텔리센스 컴파일러는 그걸 인식하는지 코드에 빨간줄을 긋지 않는다. 두 말할 나위 없이 마소에서 자기 컴파일러를 C++ 표준 내지 인텔리센스용 EDG 컴파일러의 동작을 참고하여 추후에 개선한 셈이다.

3. 멤버 함수를 가리키는 템플릿 인자

수 년 전에 본인은 템플릿 인자에 단순 함수 포인터나 functor가 아니라 C++ 멤버 함수도 들어갈 수 있는 걸 발견하고 이게 신기하다고 글을 올린 적이 있다. (☞ 관련 링크)

요약하자면 template<typename T> class Foo에다가는 멤버 변수처럼 T bar를 선언한 뒤,
Foo<int(PCSTR)> f를 선언하고 template<> int Foo<int(PCSTR)>::Bar(PCSTR p) 라고 specialize된 함수 몸체를 정의하면 된다. 그러면 n = f.Bar("kekeke")를 할 수 있다.

그런데.. 이건 역시 너무 사기적이고 사악했는지.. 후대의 컴파일러에서는 지원이 끊기고 봉인됐다.
Visual C++의 경우 딱 2010까지만 지원되며, 2012부터는 C2207 a member of a class template cannot acquire a function type 에러와 함께 컴파일이 거부된다.

그리고 사실은 2010도 인텔리센스 컴파일러는 마소 컴파일러보다 시대를 앞서 갔는지, 이걸 에러로 처리하고 있었다. 단지, 에러가 발생하는 지점이 서로 다르다.
인텔리센스는 template<> int Foo<int (PCSTR)>::bar(PCSTR s) 요렇게 멤버 함수 몸체를 정의하는 부분에서 에러를 찍지만 VC++ 후대 컴파일러는 Foo<int(PCSTR)> obj; 이렇게 템플릿을 찍어내는 과정에서 에러를 찍더라.

템플릿의 인자가 :: 연산자와 함께 다른 명칭의 일부로 들어갔을 때, 그 전체 명칭이 타입명인지 변수명인지가 오락가락 한다는 이유로 typename이라는 키워드가 도입됐다.
그것처럼 템플릿 인자가 non-static한 멤버의 변수가 될 수도 있고 함수도 될 수 있는 건 무질서도가 너무 크긴 하다. static 멤버라면 함수라도 단순 포인터로 간편하게 취급할 수 있지만 non-static 멤버 함수는 그렇지 않으니까..

그러면 저 문법은 완전히 사용 금지됐는지, 아니면 멤버 함수를 템플릿 인자로 전하는 다른 방법이 있는지 그건 잘 모르겠다. 일단 멤버 포인터라는 물건 자체가 워낙 무시무시한 놈이어서 말이다.

4. friend 키워드의 클래스 명칭 인식 방식

어떤 헤더 파일 내부에.. global scope에서 class A가 먼저 선언되었다. 그 다음으로 namespace에 소속된 클래스 B가 선언되었고, B는 내부에서 class A를 friend로 선언했다 (friend class A).
Visual C++은 이 코드에서 우리 namespace에 속하지는 않지만 밖에서 먼저 정의되어 있는 A를 인식했으며, A의 멤버 함수가 B의 비공개 멤버에 접근하는 것을 허용했다.
그러나 xcode, 안드로이드 NDK 등 타 플랫폼의 C++ 컴파일러들은 A를 인식하지 못하고 에러를 내뱉었다.

이 문제의 해결 방법은 간단하다. 그냥 A라고 하지 말고 friend class ::A라고 써 주면 된다.
그럼 Visual C++은 함수 인자의 ADL 같은 것도 아닌 상황에서 왜 유도리를 발휘한 건지 궁금해진다. 심지어 이건 인텔리센스도 동일하게 맞는 문법으로 인정해 줬다.

어떤 클래스 B가 다른 클래스 A를 friend로 선언할 때, A의 명칭은 진짜 아무거나 적어 줘도 된다. friend 선언 당시에 A가 class A; 라고 달랑 전방 선언(forward)만 됐건, 아니면 심지어 전혀 선언되지 않은 듣보잡 이름이어도 된다. friend부터 맺은 뒤에 다음에 A를 선언해도 된다.
단, Visual C++의 경우, 친구 클래스 A를 인식하는 방식에서 다음과 같은 추가적인 특성이 있었다.

  • 앞의 경우처럼 A가 아닌 ::A라고 명시하려면 A는 그 전에 어떤 형태로든 global scope 어딘가에 선언이 돼 있어야 하더라. 그렇지 않으면 Visual C++이라도 에러가 난다.
  • A가 B와 동일한 namespace에 존재한다면 아무 문제 없다. B에서 friend class A만 해 준 뒤, A는 B의 앞에 있건 뒤에 있건 자유롭게 인식 가능하다.
  • A가 그냥 global scope에 있고 B와 동일한 namespace 소속이 아닌데 friend class A만으로 A가 인식되려면 A는 B보다 먼저 선언되어 있어야 한다. 안 그러면 B의 친구는 namespace에 소속돼 있는 가상의 A로 간주되고, ::A는 제외된다.

다시 말해 자신과 다른 namespace 소속의 클래스를 친구로 지목하려면 친구 대상을 반드시 먼저 선언해 주고 :: 연산자도 동원하는 등, 통상적인 friend에 비해 문법에 약간 제약이 걸린다는 걸 알 수 있다. Visual C++은 표준을 따르고 있는 건지는 잘 모르겠지만 그 과정에서 약간 더 유도리를 제공하고 있는 것 같다.

Posted by 사무엘

2019/01/10 08:37 2019/01/10 08:37
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1574

Trackback URL : http://moogi.new21.org/tc/trackback/1574

Leave a comment

C++에는 using이라고.. class, namespace, template, virtual, operator 이런 것보다는 좀 생소하고 덜 쓰이는 키워드가 있다.
일반적인 프로그래머라면 타이핑 수고를 덜기 위해서 using namespace std; 정도 선언할 때나 사용했던 게 전부일 것이다.

얘는 C의 키워드로 치면 그나마 typedef와 성격이 얼추 비슷해 보인다. typedef는 여러 토큰으로 구성된 복잡한 타입 명칭을 한 단어(식별자) 한 토큰으로 축약해 준다.
타입 명칭이란 건 unsigned long처럼 예약어만으로도 두 단어 이상으로 구성될 수 있으며, 포인터형 *이라든가 const/volatile modifier 등이 붙어서 더욱 복잡해질 수 있다.

그러니 이런 걸 축약하는 기능은 단순히 토큰을 기계적으로 치환하는 #define 전처리기 계층이 아니라 컴파일러 계층에서 반드시 필요하다. 가령, PSTR a, b를 char *a, *b로 자동으로 인식되게 바꾸는 것은 #define만으로는 문법적으로 불가능하기 때문이다. 더구나 함수의 포인터 타입은.. 가리키는 함수가 받아들이는 인자들의 개수와 타입을 일일이 그런 식으로 나열해야 한다~!

C에서는 구조체형 변수를 선언할 때 반드시 struct를 일일이 붙여서 struct ABC 이런 식으로 선언해야 했다. struct를 생략하고 바로 ABC 한 단어만으로 쓰려면 이것조차도 typedef를 해 줘야 됐다.
그러니 C에서는 구조체를 typedef struct _ABC { ... } ABC; 이렇게 두벌일을 하면서 선언하는 게 관행이었으나..

C++에서는 객체지향 이념이 강화되면서 번거롭게 typedef를 안 해도 struct/class를 생략하고 곧바로 그 타입을 쓸 수 있게 됐다. 사실 이게 당연하고 더 자연스러운 조치가 아닌가 생각한다.

뭐 아무튼 typedef는 그런 중요한 역할을 하는 물건이다.
typedef를 통해 새로 만들어진 명칭은 사람이 보기에만 서로 다를 뿐, 컴파일러의 입장에서는 서로 완전히 동치이다. 전문 용어로 표현하자면 syntactic sugar이다.

내부적으로 담고 있는 물건은 동일하지만(똑같은 정수??) 서로 다른 타입으로 취급되어서 명시적인 형변환 없이는 서로 덥석 대입되지 않는 파생 타입.. 이런 걸 생성할 수 있으면 좋을 텐데 C/C++에서는 그게 쉽지 않다.
그러니 unsigned short/int와는 미묘하게 다른 wchar_t 같은 타입은 컴파일러가 언어 차원에서 직통으로 지원해 주지 않으면 사용자가 만들어 내기 난감하다.

그리고 HWND, HMODULE처럼 서로 호환되지 않는 다양한 핸들 타입도 내부적으로는 dummy 구조체의 포인터형을 일일이 typedef하는 편법을 동원해야 선언할 수 있다.
마치 include guard 삽질을 대체하기 위해 #pragma once가 사실상의 표준 형태로 등극한 것처럼.. 저것도 앞으로는 C++ 언어 차원에서 개선되어야 할 점이 아닌가 한다. 정수형에 대해서는 부분적이나마 type safety를 강화하려고 정수와 무작정 호환되지 않는 enum class 같은 것도 2010년대 들어서 도입된 바 있다.

아무튼, typedef는 통상적인 사유로 인해 길어진 type 명칭을 한데 줄이며, 축약된 명칭을 현재의 scope에다 도입해 준다.
그런데 using도 긴 명칭을 줄여 준다는 점에서는 역할이 비슷하다. 단지 그 배경이 typedef와는 완전히 다를 뿐이다.
바로, 지금 문맥과는 다른 namespace에 속한 명칭을 일일이 namespace를 명시하지 않고도 곧장 참조 가능하게 해 준다. 뭐, 개념은 그러하지만 구체적인 세부 문법과 용례는 생각보다 복잡하며, 본인 역시 이를 다 정확하게 알지는 못한다.

using은 크게 선언(declaration)과 지시(directive)라는 두 형태로 나뉘어서 문법적으로 서로 다르게 취급된다. 전자는..

using std::vector;

이런 식으로 구체적인 명칭을 써 주는 형태이다. 위의 경우 이 scope에서는 이제 앞에 std::를 안 붙여도 vector 클래스를 쓸 수 있게 된다. 사용되는 곳이 클래스의 내부라면 굳이 namespace 말고 기반 클래스 같은 타 클래스의 이름이 들어와도 된다.

std::vector를 vector로 줄여 쓰는 것은 기존의 #define이나 typedef로 가능하지 않다. 특히 typedef의 경우,

typedef std::vector<int> vector; //????

템플릿 인자가 모두 주어져서 온전한 type으로 실현된 놈이라면 저렇게 단축 명칭을 부여할 수 있겠지만, 그렇지 않은 추상적인 명칭을 축약하지는 못하기 때문이다. C++의 상속과 연계를 위해 dynamic_cast가 도입된 것처럼, C++에서 도입된 다단계 scope과의 연계를 위해 예전에는 없던 완전히 새로운 명칭 축약 기능이 필요해진 셈이다.

그리고 후자인 using 지시는.. using namespace라는 두 단어로 시작하여 이 namespace에 속하는 모든 명칭들을 곧장 자동 개방해 버린다.
선언이건 지시건 하는 일은 별 차이가 없다. 이것도 그냥 와일드카드에 속하는 ... 이나 * 를 써서 using std::...; 같은 선언으로 통합해 버려도 될 것 같은데, 미관상 보기 안 좋아서 그렇게 안 했나 보다.

물론 일부러 구분해 놓은 걸 당장 쓰기 편하다는 이유로 몽땅 개방해서 내 명칭과 뒤섞어 버리는 건 전역 변수, friend, public의 남발만큼이나 경계해야 할 일이다. 하지만 적절하게 활용하는 건 auto를 쓰는 것만큼이나 코드를 짧고 간결하게 만드는 약이 될 수도 있다.

C++ 표준 라이브러리의 경우 namespace가 도입되기 전 코드와의 호환을 지키기 위해 <iostream.h>는 std로 감싸져 있지 않고, <iostream>은 감싸져 있는 것으로 잘 알려져 있다. 물론 .h 버전은 앞으로 사용을 권하지 않는 deprecated로 철저히 봉인됐고 말이다.

요 두 가지가 using의 전통적인 기능이었다.
그런데 C++11 이후에는 using이 typedef의 기존 기능까지 흡수하여 본격적인 타입 alias 전담 키워드로 등극하기 시작했다. 바로, 등호를 이용해서

using P_INT = int*;
using PF_INT = int (*)();

위와 같이 써 주면 아래의 typedef와 완전히 동치가 된다.

typedef int* P_INT;
typedef int (*P_INT)();

굉장히 참신하다. 새 명칭과 치환 대상 타입이 =를 경계로 딱 분리되어 있다 보니 재래식 typedef보다 깔끔하고 알아보기도 더 쉽다.
using이라는 단어는 파스칼의 use 키워드와 비슷한 느낌이며, using A=B는 파스칼의 type A=B와 뭔가 닮은 것 같다. 또한 이 문법은 namespace에 대한 alias를 만드는 namespace A = B::C 같은 문법과도 일관성이 있다.

Visual C++에서는 한 2013쯤부터 지원되기 시작했다. 2010은 지원 안 하고, 2012는 인텔리센스 컴파일러는 지원하지만 본 컴파일러는 지원하지 않더라.
이름 없는 namespace를 선언해서 C의 static 전역 변수/함수를 표현하듯이, C++의 키워드를 이용해서 기존 C의 기능을 대체하는 예가 하나 더 생겼다. 최신 컴파일러에서는 using을 볼 일이 더 많아지겠다.

Posted by 사무엘

2018/12/15 08:32 2018/12/15 08:32
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1565

Trackback URL : http://moogi.new21.org/tc/trackback/1565

Leave a comment

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

class A { public: int pub_mem; };

class B: virtual protected A {};

class C: virtual public A {};

class D: public B, private C {};

D o; o.pub_mem = 100;

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

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

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

아무튼.. 참 복잡하기 그지없다.
그래서 C++ 이후의 언어들은 상속은 그냥 부모 멤버들의 공개 등급을 그대로 유지하는 public 상속만 생각하는 편이다. 다중 상속을 봉인해 버렸다는 건 더 말하면 입만 아플 것이고.

단지, 새로 선언하는 클래스 자체에 공개 등급을 부여할 수 있으며, 필요한 경우 추가적인 상속을 아예 할 수 없게 지정할 수도 있다. 이것이 C#에서는 sealed이고, Java는 final인데, 정작 이런 개념은 C++에는 존재하지 않는다. 아무튼 C++이 객체지향 언어로서 여러 실험과 시행착오를 많이 했고 거기서 너무 무리수로 여겨지는 개념들은 후대의 언어에서는 짤렸다고 생각하면 되겠다.

Posted by 사무엘

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

Trackback URL : http://moogi.new21.org/tc/trackback/1549

Leave a comment

1. rc 파일의 유니코드화

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

4. 문자형의 부호 문제

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Posted by 사무엘

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

Trackback URL : http://moogi.new21.org/tc/trackback/1537

Comments List

  1. 방문객 2018/09/29 14:43 # M/D Reply Permalink

    안 그래도 Windows에서 UTF-8을 지원하려는 움직임이 있는 것 같습니다. Insider 버전에서 UTF-8 사용이라는 설정을 찾아볼 수 있었습니다: https://puu.sh/BCKTc/160cf41d7f.png

    1. 사무엘 2018/09/29 20:41 # M/D Permalink

      아하 그렇군요~! 머지않아 chcp 65001이 가능해지고, Windows의 A 함수와 W 함수는 UTF-8과 UTF-16의 차이밖에 없어지는 날이 올 듯합니다. ^^;;
      레거시 프로그램들과의 호환은.. 지금 마소에서 per-process, per-monitor등 정밀한 high DPI 지원을 위해서 기를 쓰고 노력하는 정도의 샌드박스 계층만 만들면 얼마든지 가능하지 않겠나 개인적으로 생각해요. manifest 파일에 native UTF8을 지원한다고 명시해 주면 되겠죠.

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/1530

Leave a comment

코딩을 하다 보면 한 자료형(타입)에 속하는 값 내지 개체를 다른 타입으로 변환해야 할 때가 있다. 아주 직관적이거나(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

Trackback URL : http://moogi.new21.org/tc/trackback/1471

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/1441

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/1432

Comments List

  1. aaa 2017/12/25 23:28 # M/D Reply Permalink

    "1. 오버로딩과 오버라이딩의 관계" 에서 언급하신 부분은 "메써드 하이딩"이라는 기능입니다.
    디자인 실수라기 보기에는 나름 의미있는 기능입니다.

    1. 사무엘 2017/12/26 04:19 # M/D Permalink

      네, 찾아봤더니 단순히 비공개 멤버로 지정하는 은닉 말고, 그런 기능도 있더군요.
      본문은 C++ 언어의 설계가 실수가 있어 보인다는 얘기가 아니라, 제가 그런 언어의 '은닉' 특성에 대해 잘 모르고 오버로딩과 오버라이딩이 동시에 가능한 듯이 클래스를 설계하는 실수를 잠시 했다는 뜻입니다. ^^

Leave a comment

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

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

Trackback URL : http://moogi.new21.org/tc/trackback/1412

Leave a comment

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

Trackback URL : http://moogi.new21.org/tc/trackback/1342

Leave a comment
« Previous : 1 : 2 : 3 : 4 : 5 : ... 7 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/01   »
    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:
1098030
Today:
189
Yesterday:
378