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

* 이 글의 내용은 클립보드 API, const 이야기의 연장선상에 있다. 관심 있으신 분은 예전에 썼던 글을 먼저 읽어 보시기 바란다.

1.
Windows API에는 GetPriorityClipboardFormat라는 함수가 있다. 이것은 클립보드로부터 '붙여넣기'(paste) 기능이 있는 프로그램이 사용할 만한 함수이다. 이 함수가 지목하는 포맷에 해당하는 클립보드 내용을 붙여넣으면 되며, 사실은 그보다도 먼저 이 함수의 실행 결과에 따라 '붙여넣기' 메뉴를 enable/disable시킬지를 결정하면 된다.

내 프로그램이 지원하는 클립보드 포맷이 단 하나밖에 없다면 IsClipboardFormatAvailable을 쓰면 된다. 그렇지 않고 여러 종류의 포맷을 지원하는데, 현재 클립보드에 존재하는 포맷 중에 우선순위가 가장 높은 놈은 무엇인지 알고 싶다면 말 그대로 저 priority라는 단어가 들어간 함수를 써서 요청을 해야 한다. 이것은 마치 커널 오브젝트의 동기화에서 쓰이는 WaitForSingleObject와 WaitForMultipleObjects 함수쌍의 관계와 일면 비슷하다.

그런데 내가 궁금한 점은 이 함수의 첫째 인자가 const UINT *가 아니라 왜 그냥 UINT *냐는 것이다.
첫째 인자는 함수로 전달하는 복수 개의 클립보드 포맷 리스트의 포인터이며, 속성이 엄연히 out이 아닌 in이라고 명시되어 있다. 저 함수 역시 인자로 받은 리스트 내용을 참조만 할 것이니, 포인터가 read-only가 아니라 일반 read-write 형태여야 할 하등의 이유가 없다. WaitForMultipleObjects만 해도 HANDLE의 배열 포인터는 분명히 const HANDLE *로 잡혀 있지 않던가?

저게 const라면,

static const table[] = { CF_UNICODETEXT, CF_HDROP }; //사람이 텍스트로 입력한 파일명 & 탐색기에서 복사해 놓은 파일명을 모두 받아들임
int res = GetPriorityClipboardFormat(table, ARRAYSIZE(table));

이런 식으로 간편하게 static const 배열을 함수에다 넘겨 줄 수 있을 텐데, const가 아니기 때문에 일이 좀 귀찮아진다. table에 const 속성을 제거하든지, 아니면 함수에다 넘겨 줄 때 const_cast 연산자를 써 줘야 한다. 내 경험상, 강제로 const 포인터로 고친다고 해서 딱히 프로그램 동작에 문제가 생긴다거나 하는 건 아니더라.

하지만 내 프로그램 내부에서 값을 고칠 일이 절대로 없기 때문에 const로 엄연히 봉인해 버리고 싶은 테이블을 봉인하지 못한다니, 썩 보기 좋아 보이지 않는다. 또한 const_cast 같은 연산자가 늘어나는 건 내 소스 코드에서 무질서도가 쓸데없이 증가하는 듯한 느낌을 준다.

물론, 동일한 유형의 데이터에 대해서 클립보드 포맷을 복수 개 지원한다는 건 대개가 custom 포맷과 표준 포맷을 혼용하는 경우이다. 리스트의 모든 원소가 상수가 아니며, 우선적으로 선택되었으면 하는 앞부분은 어차피 RegisterClipboardFormat의 리턴값인 경우가 많다. 워드 프로세서라면 모든 정보가 보관되어 있는 고유 포맷일 것이고, 텍스트 에디터라면 칼럼 블록을 명시한 고유 포맷이다.

그러니 저 함수의 인자로 전달되는 배열 포인터는 애초부터 const가 아니라 일반 배열일 가능성이 더 높다. 메시지만 해도, 시스템 차원에서의 유일성을 보장받기 위해 RegisterWindowMessage로 특수하게 등록한 메시지는 WM_USER+x와는 달리 가변적인 값이다. 그렇기 때문에 윈도우 프로시저에서 switch/case를 이용해 걸러낼 수가 없으며 if문을 써서 비교해야 하지 않던가. (이 두 Register* 함수는 프로토타입이 UINT Foo(PCTSTR name)로 완전히 동일하며, 명칭을 hash하여 숫자로 바꿔 준다는 공통점이 있어서 더욱 동질감이 느껴진다)

그런데.. 그런 식으로 치자면 WaitForMultipleObjects가 받아들이는 HANDLE도 NULL이나 INVALID_HANDLE_VALUE 같은 오류 상황 말고는 고정불변 상수값이 존재할 여지가 전혀 없는 타입이다. 값의 가변/불변성과는 무관하게 해당 함수가 값들을 읽기만 한다면 함수 인자가 응당 const 포인터로 잡혀 있어야 할 텐데, 클립보드의 저 함수는 왜 저렇게 설계되었으며 디자인에 문제를 제기하는 사람이 왜 없는지 궁금하다.

2.
서식이 없고 칼럼 블록도 없는 일반 텍스트 에디터에서 붙이기를 구현한다면 굳이 custom 포맷을 등록할 필요는 없을 것이고 애초에 복수 개의 클립보드 포맷을 살펴볼 필요도 없을 것이다. 단, 여기에도 원래는 유니코드/ANSI와 관련된 미묘한 문제가 있다. wide 문자 기반인 CF_UNICODETEXT와, ansi 문자 기반인 CF_TEXT가 따로 존재하기 때문이다.

Windows NT는 이들 사이에서 '유도리'를 제공한다.
응용 프로그램이 유니코드 텍스트 형태(CF_UNICODETEXT)로 클립보드에다 텍스트를 복사해 놓은 뒤, 나중에 유니코드를 지원하지 않는 프로그램이 클립보드로부터 CF_TEXT 포맷을 요청하는 경우 운영체제는 CF_TEXT도 있다고 답변을 하며, CF_UNICODETEXT로부터 CF_TEXT 데이터도 자동으로 만들어 준다. 그 반대도 물론 해 준다. (복사는 CF_TEXT로, 붙여넣기는 CF_UNICODETEXT로)

마치 Get/SetWindowText, WM_GET/SETTEXT에서 운영체제가 A/W 문자열 보정을 하듯이, 클립보드에 대해서도 그런 보정을 해 준다. 재미있지 않은가? 그러니 오늘날의 Windows에서는 유니코드를 지원하는 프로그램은 어떤 경우든 CF_UNICODETEXT만 생각하고 요걸로만 읽고 쓰면 유니코드를 지원하지 않는 구시대 프로그램과도 의사소통에 아무 문제가 없다.

하지만 문제가 되는 건 내부적으로 유니코드를 사용하는 프로그램이 Windows 9x까지 고려해서 만들어질 때이다. 9x 계열에서는 CF_UNICODETEXT는 응용 프로그램이 굳이 요청해서 자기들끼리 쓰지 않는 한, 운영체제 차원에서 공식적으로는 '없는 포맷', 지원하지 않는 유령/공기 포맷이다. 고로 NT 계열과 같은 타입 자동 변환이 없다.

텍스트 에디터가 자기는 내부적으로 유니코드 기반이라고 해서 자신이 native로 사용하는 CF_UNICODETEXT 형태로만 데이터를 복사해 놓으면, 기존 메모장 같은 프로그램에서는 텍스트 붙이기를 할 수 없게 된다.
그렇기 때문에 9x 계열과 유니코드라는 두 토끼를 모두 잡으려면 응용 프로그램은 클립보드로부터 데이터를 읽을 때도 IsClipboardFormatAvailable가 아니라 저 GetPriorityClipboardFormat를 써서 CF_UNICODETEXT와 CF_TEXT를 모두 살펴봐야 한다. 물론 1순위는 유니코드로 하고 말이다.

그리고 빈 클립보드에다 텍스트를 복사해 넣을 때에도 CF_UNICODETEXT부터 먼저 집어넣은 뒤, CF_TEXT 타입도 자동으로 생겨 있는지를 IsClipboardFormatAvailable로 체크하고, 없으면 ansi 텍스트도 수동으로 넣어 줘야 한다. Windows NT에서는 유니코드 텍스트만 집어넣어도 CF_TEXT로 붙여넣는 게 저절로 가능하지만 9x에서는 그렇지 않기 때문이다.

참 얼마 만에 클립보드 얘기를 또 꺼내게 됐는지 모르겠다. 지금이야 먼 과거의 유물이 됐다만, Windows 9x 시절에는 텍스트 하나를 유니코드 형태로 주고받을 때는 이렇게 미묘하게 귀찮은 면모가 좀 있었다.

클립보드에다 데이터를 지정하는 SetClipboardData는 GMEM_MOVEABLE을 지정해서 GlobalAlloc으로 평범하게 할당한 메모리 핸들을 넘겨 주는 방식으로 사용하면 된다. 함수를 호출한 뒤부터는 그 메모리 핸들은 운영체제의 관할로 넘어가기 때문에 우리가 임의로 해제해서는 안 된다.
이것과 굉장히 비슷한 개념으로 운용되는 다른 함수는 SetWindowRgn인 것 같다. 평범한 HRGN을 넘겨 주지만 이제부터 이 region은 운영체제의 관할이 되기 때문이다. 우리가 더 건드려서는 안 된다.

3.
C/C++은 여느 언어와 마찬가지로 const라는 속성이 존재하는데, 이것은 (1) 완전히 붙박이 상수가 될 수 있으며, 아니면 (2) 런타임 때 가변적인 초기값이 붙는 것도 가능하지만, 한번 값이 정해진 뒤에는 불변이라는 의미도 될 수 있다.
이거 혼동하기 쉬운 개념이다. C/C++의 const는 (1)은 딱히 보장하지 않고 일단은 (2)만 보장한다.

const형 변수를 초기화하는 초기값으로는 반드시 case 레이블이나 static 배열의 크기처럼 컴파일 타임 때 값이 결정되는 상수만 와야 하는 게 아니다. 변수도 얼마든지 초기값으로 지정할 수 있으며, 이건 그냥 const뿐만 아니라 static const에 대해서도 마찬가지이다. 애초에 const는 타입에 제약이 없으니까. (1)을 정수 명칭에 한해서 보장해 주는 건 enum, 아니면 템플릿 인자이다.

그래서 C#은 이 두 개념을 const와 readonly로 확실하게 구분해 놓았는데, 이건 상당히 매력적인 조치라 여겨진다. 배열· 테이블도 const로 지정이 가능하니까 말이다. 또한 붙박이 불변 const는 스택에든 어디든지 여러 인스턴스가 중복해서 존재할 이유가 하등 없으니 static 속성도 자동으로 포함하는 걸로 간주된다.

한편, 포인터가 const라면(가령 p) p 자신의 값을 바꿀 수 없는지, 아니면 p가 가리키는 메모리의 값을 바꿀 수 없는지(*p 혹은 p->)가 문제가 된다. const TYPE *p라고 하면 *p가 잠기고, TYPE * const p라고 하면 p가 잠긴다. const TYPE * const p라고 하면 짐작하시겠지만 둘 다 잠긴다.
문법이 좀 복잡한데, const T와 T const는 의미상 동일하다. 그리고 포인터값 자체를 잠글 때는 * 뒤에다가 const를 추가로 붙인다고 생각하면 비교적 직관적으로 이해할 수 있다.

하지만 포인터라는 게 지니는 값은 NULL 같은 특수한 경우가 아니고서야 언제나 가변적인 메모리 주소이기 때문에.. 포인터에다 const는 p 자신이 아닌 *p를 잠그는 용도로 훨씬 더 많이 쓰인다. 클래스 멤버 함수에 붙는 const도 this 포인터에 대해서 *this를 읽기 전용으로 잠그는 역할을 한다. this는 예약어이다 보니 this의 주소값 자체를 변경하는 건 어떤 경우든 원래부터 가능하지 않으니 말이다.

그리고 *p가 잠긴 const 포인터에 대해서 그 잠김을 일시적으로 해제하는 형변환 연산자는 잘 알다시피 const_cast이다. 다만, const가 사라진 TYPE *라는 타입명을 그 뒤의 <> 안에다가 수동으로 일일이 써 줘야 한다. 이름은 const_cast이지만 얘는 '쓰기 금지'뿐만 아니라 언어 차원에서의 타입과 "무관하게" 변수 접근 방식과 관련하여 붙어 있는 추가적인 제약 속성을 제거할 때에도 쓰인다. volatile이 대표적인 예이고, 비주얼 C++의 경우 IA64 아키텍처 기준으로 __unaligned 속성도 이걸로 제거할 수 있다. (IA64는 기본적으로 machine word 단위로 align이 된 곳만 메모리 접근이 가능한지라..)

Windows API에는 포인터의 const-ness 구분 때문에 불편한 게 몇 군데 있다. 대표적인 건 CreateProcess 함수.
실행할 프로세스를 가리키는 명령줄은 PCTSTR이 아니라 PTSTR이다. 함수가 내부적으로 실행될 때는 파일명과 인자명을 구분하기 위해서 문자열의 중간에 \0을 삽입해서 토큰화를 하기 때문이다. 즉, strtok 함수가 동작하는 것처럼 토큰화를 하며 문자열을 변경한다.

이 함수는 실행 후에 문자열 내용을 다시 원래대로 되돌려 놓긴 하지만, 명령줄 문자열을 나타내는 메모리는 일단 read-only가 아니라 쓰기가 가능한 놈이어야 한다. *a+=0; 을 수행하려면 비록 *a의 값이 실제로 변경되지는 않지만 그래도 가리키는 메모리가 변경 가능해야 하는 것과 비슷한 이치이다.
Windows NT 계열에서 CreateProcessA 함수를 호출하면 얘는 어차피 문자열을 쓰기 가능한 문자열에다가 유니코드로 변환해서 내부적으로 W 함수가 사용된다. 그렇기 때문에 A함수에다가 (PSTR)"notepad.exe sample.txt" 이런 식으로 줘도 안전하긴 하다. 그러나 범용성을 고려하면 이는 그렇게 깔끔한 코드라고 보기 어렵다.

다음으로 DrawText도 기능을 제공하는 형태가 조금 지저분하다.
얘는 문자열을 받아들여서 출력하는데 폭이 부족할 경우 문자열을 있는 그대로 출력하는 게 아니라 "Abc..."라고 줄여서 출력하는 옵션이 있다. 그런데 사용자가 원한다면 자기가 받은 문자열 자체를 그렇게 출력되는 형태로 '수정해' 주는 옵션이 있다(DT_MODIFYSTRING). 이 옵션을 사용한다면 str은 PCTSTR이라는 프로토타입과는 달리 쓰기 가능한 포인터여야 한다.
또한 RECT *인자도 좀 아쉬운 구석이 있다. 일단은 in/out 겸용이어서 RECT *이지만, 이것도 out 없이 문자열을 찍을 영역만 지정하는 in 전용이 가능했으면 좋겠다.

끝으로, 리스트/트리 같은 공용 컨트롤에 데이터를 추가하는 LVITEM이 있다. 이들 구조체의 pszText는 PCTSTR이 아니라 PTSTR이다. 문자열을 얻어 올 때와 지정할 때 모두 동일한 구조체가 쓰이기 때문에 const 속성이 없는 것이다.
이 때문에 컨트롤에다 데이터를 처음 추가할 때 item.pszText=const_cast<PSTR>("Hello") 같은 연산이 부득이 쓰이기도 한다.

C++과는 달리 자바에는 클래스 멤버 함수라든가, call-by-reference로 전달되는 데이터에 대해서 const-ness를 보증하는 개념이 없다. 클래스에서 Get*으로 시작하는 메소드라고 해서 const를 따로 지정하지는 않으며, final 키워드는 함수의 오버라이드 불가, 값의 변경 불가 같은 '종결자' 용도로만 쓰인다. 과연 const가 없어도 괜찮은 걸까?

그런데 포인터의 단계가 복잡해지다 보면, const가 있어 봤자 가리키는 놈이 const인지, 나 자신이 const인지 일일이 따지고 추적해서 내부 상태의 불변성을 보장하겠다는 시도가 별 의미가 없어지긴 한다. 즉, this가 this와 *this까지 몽땅 봉인되어서 나 자신의 멤버 값은 변경할 수 없지만.. 내가 가리키는 메모리 영역의 값을 한 단계 거칠 수가 있으며 거기 값을 변경하는 게 가능하다면.. 궁극적으로 그 개체의 속성/상태는 바뀌었다고 볼 수 있기 때문이다.

어떤 코드를 실행해 보지 않고 메모리 접근이 안전한지 입증하는 정적 분석 기능이 만들기 까다로운 것도 본질적으로 이런 이유 때문이다. 컴파일러의 경고가 동작을 예측하고 비정상을 지적해 줄 수 있는 것도 간단한 지역변수 수준일 뿐이지, 몇 단계짜리 포인터에 구조체가 줄줄이 따라오면.. 답이 없다.

거기에다 클래스의 인터페이스 상으로는 Set*이 아니라 Get*처럼 "read-only"이고 값이 변하지 않는 기능만 사용한다고 하더라도, 클래스의 내부 상태는 얼마든지 바뀔 수가 있다. 한번 구해 놓은 계산값을 다음에는 빠르게 되돌리기 위해서 그 값 자체 내지 중간 상태를 캐싱하는 것처럼 말이다. 이런 경우가 워낙 많으니 C++에서는 나중엔 멤버의 const-ness에 예외를 허용하는 mutable 키워드도 도입하지 않을 수 없었다.

이건 마치 고유명사와 보통명사의 경계와도 비슷한 맥락의 문제인 것 같다. 로마자 알파벳이 한글과는 달리 대문자가 있고 고유명사는 첫 글자를 대문자로 적게 돼 있어서 일면 편리한 건 사실이다. 하지만 좀 깊게 생각을 해 보면, 새로운 명칭이 만들어졌을 때 그걸 고유명사로 볼지 기준이 생각만치 엄밀하지는 않은 경우가 많다. 기준이 생각만치 엄밀하지 않으며 그냥 자의적이다. 번역하지 않고 음역하는 놈은 죄다 고유명사인 걸까? 실체가 지구상에 오로지 하나밖에 없는 놈? 그렇다면 실물이 존재하지 않는 무형의 개념은? 그걸 정하는 기준은 무엇?

지금 우리가 보통명사로 별 생각 없이 쓰는 명칭들도 그 명칭이 처음 등장했던 시절엔 첫 글자를 대문자로 적는 유니크한 고유명사이기도 했다. const 개념 구분도, 대소문자 구분도 있건 없건 어느 하나가 다른 하나보다 절대적으로 우월하고 좋은 솔루션은 아니어 보인다는 생각이 든다.

에휴, 클립보드부터 시작해서 유니코드, const 등 별별 이야기가 다 이어져 나왔다. 이상~~ ^^

Posted by 사무엘

2016/02/12 08:36 2016/02/12 08:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1192

C/C++에서 대괄호 [ ]는 일단 배열과 관련이 있는 문자이다.
타입을 선언하는 문맥에서 a[...]라고 하면 a가 배열임을 나타내고 선언한다. [] 안에는 배열의 크기를 나타내는 정수 상수(= 컴파일 타임 때 바로 값이 결정되는)만이 올 수 있다.
그 반면 값을 계산하는 문맥에서 a[...]는 일단은 배열이나 포인터를 역참조하는 *(a+...)와 동치가 되기 때문에, 상수가 아닌 임의의 변수값이 들어올 수 있다. 게다가 C++에서는 []를 연산자로서 오버로드도 할 수 있으므로 정수가 아닌 다른 타입의 값이 들어오는 게 가능하다.

그럼 속에 아무것도 없이 말 그대로 []만 달랑 오는 건 언제 가능할까?
일단 값을 계산하는 실행 문맥에서는 전혀 가능하지 않다. 함수 호출이 아닌 일반 수식에서 일반 괄호가 ()요렇게만 달랑 올 수가 없는 것과 같은 이치이다. 또한 인자가 전혀 없는 형태로 [] 연산자 함수를 오버로딩 하는 것도 안 된다.
얘가 쓰이는 가장 유용하고 편리하고 직관적인 상황은, 이니셜라이저로부터 크기를 자동으로 유추할 수 있는 배열을 선언하여 곧장 초기화할 때이다.

char pt[] = "Hello"; //null 문자까지 합해서 6이라는 크기가 자동으로 유추됨.
static const int samp[] = {1, 5, 7, 8}; //4라는 크기가 자동으로 유추됨.

이 정도야 []는 명백하게 배열을 나타내며, 그 의미를 충분히 납득할 수 있다. 헷갈릴 게 없다.
그리고 지역변수 말고 다른 번역 단위에 있는 배열을 요런 게 있다고 선언만 해 놓는 문맥일 때도 구체적인 크기가 생략된 arr[] 같은 구문이 올 수 있다. 이 경우 클래스로 치면 forward 선언과 얼추 비슷한 역할을 한다. 배열을 참조하는 코드를 생성할 수는 있지만 sizeof 연산자는 쓸 수 없게 된다. 온전한 배열은 아니지만 포인터도 아니기 때문에 여기에 다른 임의의 주소를 대입할 수는 없다.

그런데 []는 일부 제한된 문맥과 범위에서는 그냥 포인터를 나타낼 때도 있다. 이거 다시 보니 굉장히 요물스럽게 느껴진다.

void foo(char ptr[]);
void foo(char *p);

위와 아래의 함수 선언은 완전히 동치이다. 그렇기 때문에 저 두 쌍으로는 함수 오버로딩을 할 수 없다.
이게 요물스럽게 느껴지는 이유는, (1) 첫째, 함수의 인자에서만 사용 가능한 문법이기 때문이다.
일반 변수를 선언할 때야 char ptr[]은 포인터가 아니라 배열로 인식된다. 그렇기 때문에 이니셜라이저 없이 저렇게만 달랑 써 놓으면 크기를 알 수 없다고 컴파일 에러가 난다.
그에 반해 함수 인자에서는 배열을 선언하거나 초기화 따위 할 일이 없으니 ptr[]은 *p와 동일하게 인식된다.

우리는 프로그램으로부터 명령 인자를 받기 위해 main 함수로부터 argc, argv 인자를 살펴본다. 그런데 정말 이상한 건 argv는 거의 언제나 관행적으로 *argv[]라고 선언해 놓고 써 왔다는 것이다. 이건 알고 보면 **argv와 본질적으로 완전히 동치일 뿐인데도 말이다. 하지만 *argv[]와 **argv는 왠지 느낌상 서로 동일해 보이지가 않는다.

함수 인자와는 달리 리턴값은 char *bar()만 가능하지 char []bar 이렇게 쓸 수 없다. 함수 인자 말고는 리턴값이나 지역 변수 선언, 형변환 연산자, typedef 등 타입을 명시하는 그 어떤 문맥에서도 []를 저런 식으로 쓰는 건 문법적으로 가능하지 않다.

(2) 둘째, []는 그렇다고 해서 함수 인자이기만 하면 *와 완전히 등가 교환이 가능한 것도 아니다.
[]의 지원 범위는 오로지 1중 포인터 한정이다. 2중 포인터인 char **a를 a[][] 따위로 대체할 수는 없으며 오로지 한 단계만 *a[]로 대체할 수 있을 뿐이다. 한 단계 포인터를 대체할 수 있다는 점에서는 C++의 참조자 &를 살짝 닮은 것 같기도 하다.

그럼 함수 인자에서는 왜 저걸 예외적으로 가능하게 해 놓은 것일까?
정확한 이유는 알 수 없지만 C/C++은 배열은 함수 인자로 전달할 때 값이 새로 복제되는 게 아니라, 언제나 주소(= 포인터) 형태로만 전달한다는 점을 부각시키기 위해서 저렇게 한 게 아닌가 싶다. N차원 배열은 언제나 N-1차원 배열의 1중 포인터가 된단 얘기다. 다차원 배열을 선언해서 함수로 전달하는 상황을 생각해 보자.

int table[][2][4]={
    {{1,2,3,4}, {3,4,5,6}}, {{2,6,2,3}, {5,6,3,4}}, {{1,}, {2,}}
};

foo(table);

사실 C/C++은 엄밀히 말하면 다차원 배열이라는 것도 없고 그저 '배열의 배열'이 있을 뿐이다.
2*4 배열의 배열을 선언한다는 건 생략할 수 없고, 유일하게 생략할 수 있는 건 제일 겉에 있는 최종 원소의 개수(저기서는 3)이다.
이런 3차원 배열을 인자로 받는 함수는 프로토타입을 어떻게 만들면 좋을까?

void foo(int a[][2][4]);

table은 함수의 인자로 전달할 때는 그대로 2*4 배열의 포인터가 되고, 그걸 함수 인자로 표현하는 걸 배열 변수를 선언할 때와 동일하게 [][2][4]로 할 수가 있다. 함수 인자에서 []의 의미는 바로 여기에 있다고 보는 게 타당할 것이다. 함수 인자에서도 [] 안에 숫자를 생략할 수 있는 건 제일 겉의 차원수 하나뿐이다.

위의 함수의 인자는 개념적으로 아래와 완전히 동일하다. *(a+2)와 a[2]가 동일한 것만큼이나 서로 동일하다.

void Fune(int (*a)[2][4]);

a[][2][4]를 사용하지 않았으면 그냥 포인터나 다중 포인터가 아니라 '배열의 포인터'를 표현하기 위해 저렇게 괄호를 쳐야 했을 텐데 [] 표기는 괄호가 또 들어갈 필요를 없애 주고 표현을 간결하게 만들어 준다.
배열 선언 table[][2][4]에서는 []는 원소 개수가 이니셜라이저에 명시돼 있다는 뜻이고, 함수 인자 a[][2][4]에서는 이게 포인터라는 뜻이라니..;;

이건 무슨 최신 C++ 기능도 아니고 C의 완전 기초 필수 아이템인데도... 본인조차 []의 의미와 용도에 대해서 제대로 진지하게 생각을 안 했던 것 같다. 신기하기 그지없다.

Posted by 사무엘

2016/02/04 08:27 2016/02/04 08:27
,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1189

컴파일러의 경고 외

군대 유머, 관제탑 유머가 있는 것처럼 변호사를 소재로 한 블랙코미디 시리즈가 있다.
돈만 주면 자기 양심과 혼까지 팔아서 온갖 미사여구와 궤변(?)으로 범죄자의 형량을 감소시키고 심지어 무죄로 조작한다는.. 변호사에 대한 좀 과장되고 왜곡된 이미지가 들어가 있다.

천당과 지옥(혹은 천사와 악마)이 법정에서 소송이 붙으면 천당/천사 진영은 아마 승산이 없을 거라는 개드립조차 있다. 왜냐하면 유능한(=타락한-_-) 변호사들은 몽땅 지옥에 가 있어서 다 악마 편이기 때문에. -_-;; 물론 영적 법정에서 실제로 하나님이 어떤 편인지를 안다면, 그리고 성경에서 judgment라든가 judge라는 단어의 용례만 쭉 뽑아 보면 개드립은 그냥 개드립일 뿐이라는 걸 알 수 있다.

그런데 변호사가 굉장히 바보 같은 질문을 할 때가 있(었)는가 보다. 예를 들어..

  • 그림을 도둑맞던 당시에 선생님/고객님은 현장에 계셨습니까?
  • 그 일을 혼자 하셨나요? 아니면 단독 범행?
  • 충돌 당시에 두 차가 얼마나 떨어져 있었죠?
  • 그 스무 살 먹었다는 막내아들이 나이가 어떻게 된댔죠?
  • 전쟁에서 죽었다는 사람이 당신이었습니까, 아니면 당신 동생이었습니까?
  • 건망증을 앓고 계셨다면, 그럼 그 동안 잊어버린 것들의 예를 좀 들어 주시죠.

도대체 저 변호사 양반이 왕년에 그 무시무시한 사법 시험을 어떻게 통과했는지, 아니면 악착같은 공부 기계 괴수들이 몰리는 로스쿨을 어떻게 들어가서 졸업했고 어떻게 변호사 시험을 합격했는지를 의심케 하는 대목이 아닐 수 없다.

바보같은 질문은 그 변호사가 너무 격무에 시달린 나머지 (1) 정말로 뇌에 나사가 좀 풀려서 감을 잃었거나, (2) 정신 없어서 의뢰인을 완전 성의없게 대해서 나올 수 있다. 하지만 한편으로는 (3) 일부러 바보 같은 질문을 던져서 일종의 심문을 하려는 의도도 있다. 같은 내용을 비비 꼰 바보 같은 질문에 낚여서 진지하게 대답하다 보면, 일관성 없는 진술이 들통날 수 있기 때문이다.

뭐, 여기서 내가 법조인들의 심리나 심문 기법 같은 걸 얘기하려는 건 아니고.
중요한 건, 자연 언어뿐만 아니라 프로그램 코드도 사람이 작성하는 것이다 보니 저런 바보 같은 문장이 있을 수 있다는 것이다. 그리고 컴파일러가 그걸 지적해 주는 것을 우리는 '경고'라고 부른다.

간단한 예로는 선언만 해 놓고 사용하지 않은 변수, 초기화하지 않고 곧장 참조하는 변수, 한쪽에서는 class로 선언했는데 나중에 몸체를 정의할 때는 동일 명칭을 struct로 규정한 것이 있다. 딱히 에러까지는 아니고 코드 생성이 가능하지만, "혹시 다른 걸 의도한 게 아니었는지" 의심할 만한 부분이다.

더 똑똑한 컴파일러는 세미콜론이나 =/==사용이 아리까리해 보이는 것도 경고로 찍으며, 이런 것도 지적해 준다.
unsigned long p; (...) if(p<0) { }

unsigned 타입의 변수를 보고 "너 혹시 0보다 작니?"라고 묻는 건 그야말로 변호사가 "당신과 당신 동생 중 전쟁에서 죽은 사람이 누구라고 했죠?"라고 묻는 것이나 다름없다. 그러니 저 if 안에 있는 코드는 unreachable이라고 지적해 주는 건 적절한 조치이다.

사실, 사람이라 해도 처음부터 대놓고 저렇게 바보 같은 문장을 작성하는 경우는 드물다. 작성한 지 오래 된 코드를 나중에 리팩터링이나 다른 수정을 하게 됐는데, 같이 고쳐져야 하는 문장이 일부만 고쳐져서 일관성이 깨지는 경우가 더 많다. 남이 int를 기준으로 작성해 놓은 코드를 나중에 후임이 UINT로 고치면서 저 if문의 존재를 잊어버린다거나(알고 보니 이 값에 음수가 들어오거나 쓰일 일은 절대 없더라). 버그도 이런 식으로 생기곤 한다.

비주얼 C++에서 경고는 총 4단계가 있다. 1단계는 정말로 말이 안 되어 보이는 것만 출력하고, 4단계까지 가면 정말 미주알고주알 별걸 다 의심스럽다고 지적한다. 경고들을 그렇게 여러 단계로 분류한 기준은 딱히 표준이 있지는 않고 그냥 컴파일러 제조사의 임의 재량인 것으로 보인다.

비주얼 C++이 프로젝트를 만들 때 지정하는 디폴트는 3단계이다. 3단계를 기준으로 깔끔하게 컴파일되게 작성하던 코드를 4단계로 바꿔서 빌드해 보면 이름 없는 구조체를 포함해서 사용되지 않은 '함수 인자'들까지 온통 경고로 뜨기 때문에 output란이 꽤 지저분해진다. 물론, 특정 경고를 그냥 꺼 버리는 #pragma warning 지시문도 있지만, 그 자체가 소스 코드를 지저분하게 만드는 일이기도 하고.

그러니 어지간하면 3단계만으로 충분하지만, 4단계 경고 중에도 컴파일러가 잡아 주면 도움이 되겠다 싶은 일관성 미스 같은 것들이 있다.
그래서 모든 사람들이 코드의 모든 구조를 알지 못하는 공동 작업을 하는 경우.. (직감보다 시스템이 차지하는 비중이 더 커짐) 그리고 팀원/팀장 중에 좀 결벽증 강박관념이 있는 사람이 있는 경우, 4단계를 기준으로 프로젝트가 진행되며, 커밋하는 코드는 반드시 경고와 에러가 하나도 없어야 한다고 못을 박곤 한다. 심지어 경고도 에러와 동등하게 간주시켜서 빌드를 더 진행되지 않게 하는 컴파일 옵션을 사용하기도 한다.

변호사 유머를 보니까 컴파일러의 경고가 생각이 나서 글을 썼다.
내 생각엔 a=a++처럼 이식성 문제가 있고 컴파일러 구현체마다 다른 결과가 나올 수 있는 코드에 대해서나 경고가 좀 나와 줬으면 좋겠다. 저것도 초기화되지 않은 변수만큼이나 문제가 될 수 있기 때문이다. 비주얼 C++의 경고 level 4 옵션으로도 저건 그냥 넘어가는 듯하다.

예전에도 얘기한 적이 있듯, 법은 사람을 제어하는 일종의 선언/논리형 프로그래밍 언어로서 컴퓨터 사고방식으로 생각할 수도 있는 물건이다. 또한, 프로그램의 버전을 얼마나 올릴지 결정하는 게 형벌의 양형 기준과 비슷한 구석이 있다.
잡다한 기능들을 많이 추가한 것, 짧고 굵직한 기능을 구현한 것, 비록 작업량은 별로 많지 않지만 현실에서의 상징성과 의미가 굉장히 큰 것, 아니면 그냥 적당히 시간이 많이 흘렀기 때문에 숫자를 팍 올리는 것.

형벌이라는 것도 사람을 n명 죽인 것에 비례해서 징역이 올라가는 그런 관계는 당연히 아닐 테니, 상당히 많은 변수들이 감안된다.
이런 것들을 다 감안해서 다음 버전의 숫자를 결정하는데 이거 굉장히 복잡하다.

Posted by 사무엘

2015/12/10 08:33 2015/12/10 08:33
, , ,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1169

몇 가지 C++ 이야기

1. 람다 함수와 내부 클래스 사이의 관계

본인은 몇 년 전엔 Visual C++ 2010으로 갈아탄 기념으로, <날개셋> 한글 입력기의 소스 코드 내부에서도 한 함수 안에 임시로 사용되던 잡다한 매크로 함수들을 대부분 람다나 템플릿 함수로 바꿨다. 이건 무식한 #define의 사용을 최소화한다는 코딩 컨벤션과 부합하는 리팩터링이었다. #define은 조건부 컴파일이나 ##처럼 정말로 컴파일러의 영역을 초월하여 전처리기의 도움이 필요한 영역에서나 제한적으로 사용할 것이다.

그 당시엔 매크로가 함수 안의 지역변수들을 건드리는 건 대부분 캡처로 기계적으로 치환해서 집어넣었다. 허나 무분별한 캡처 난발도 지금 보니 무척 지저분하게 보인다는 것을 깨닫는 데는 그리 긴 시간이 걸리지 않았다.
스케일만 한 함수 안으로 좁아졌을 뿐, 그 안에서 또 온갖 전역변수들을 덕지덕지 참고하여 결합도가 개판이 된 함수를 보는 듯한 느낌이어서 말이다. 소프트웨어공학 용어로는 common coupling에 해당한다.

여러 람다 함수가 비슷한 지역 변수들을 참조하는 경우, 그건 데이터와 함수를 몽땅 묶어서 함수 내부에 있는 local 클래스 형태로 떼어 내 버렸다. 어차피 람다의 캡처가 내부적으로는 그렇게 가상의 클래스 멤버 형태로 구현되니까 말이다.
그런데 클래스의 멤버 함수 내부에 있는 람다 함수가 지역 변수들도 참조하고 this 포인터까지 참조하는 경우.. 이건 좀 소속을 어디에다 둘지 좀 난감하긴 했다.

Visual C++ 2012부터는 드디어 캡처가 없는 람다에 한해 일반 함수 포인터로 typecast도 가능해졌다.

2. 템플릿 인자의 성격 분류

다음으로 C++ 템플릿 얘기를 하겠다.
지금까지 템플릿을 매크로 함수의 대체 용도로 사용했지만 한편으로는 이것을 공용체의 대체품으로도 잘 활용하고 있다.
예를 들어 클립보드의 내용을 type-safe하게 액세스 해 주는 클래스를 이런 식으로 사용한다.

CClipboard<WCHAR> cb(CF_UNICODETEXT);
CClipboard<DROPFILES> cb(CF_DROP);

예전에는 CClipboard라는 한 클래스 안에
union { PCWSTR pszText; DROPFILES *hDropFiles; };

라고 union을 쓰던 것을 typename T *pData 요렇게 바꾼 것이다.
어차피 한 클립보드 포맷 하에서 다양한 타입을 섞어서 쓸 일은 없으므로, 꼭 공용체가 필요한 상황이 아니기 때문이다.

그리고 템플릿 인자의 명칭을 용도별로 통일하고 있다. 크게 다음과 같은 네 가지가 있더라.

(1) 저것처럼 일반적인 임의의 type은 T이다.
(2) 참조 타입은 R이다. 이건 T 자체는 아니지만 call by value로도 전달할 수 있을 정도로 가볍고 T의 값을 온전히 나타낼 수 있는 key이다. T의 생성자에 단독 인자로 들어오는 게 가능하다.
가령, T가 String이라면 R은 const char*가 될 수 있다. 혹은 그냥 복사 생성자 격인 const T&도 상관없고.
R은 컨테이너 클래스다 추가하거나 검색하는 값을 지정할 때 쓰일 수 있다.

(3) 그리고 정수 인자는 N. 클래스에 소속된 static 배열의 원소 개수나 enum 값을 정의할 때 쓰인다.
(4) 끝으로, 람다든 functor든 함수 포인터든.. 어쨌든 실행 코드가 들어가는 부분은 O이다. F를 쓸까 하다가 기분 탓에 O가 됐다.

template<typename T, size_t N> class CStaticArray { T m_dat[N]; };
template<typename T, typename R=const T&> class CLinkedList { };
template<typename O> void EnumData(O func);

같은 식이다. 이것 말고 템플릿 인자의 용도는 또 뭐가 있을지 궁금하다.

다만, C++이 언어 문법 차원에서 템플릿 인자를 명백히 구분하는 건 정수형(N) 아니면 typename(T, R, O) 이렇게 둘뿐이다. 그러니 완전히 귀에 걸면 귀걸이이고 코에 걸면 코걸이이다. 템플릿 코드 자체만으로는 컴파일러가 문법 오류를 잡을 수 없으며, 그 템플릿에다 실제로 인자를 줘서 타입을 인스턴스화한 뒤에야 문제를 발견할 수 있다. 이것보다는 쓰임이 제한적이더라도, 좀 덜 와일드하고 통제 가능하고 쓰임을 예측 가능한 템플릿을 만들 수는 없나 싶은데 아직까지는 갈 길이 요원해 보인다.

아, 그나저나 템플릿 선언은 오로지 global scope 안에서만 가능하다. 한 함수 안에 있는 지역 클래스는 템플릿 형태로 선언될 수 없으며 일부 멤버 함수만이 템플릿 형태로 선언되는 것조차도 불가능하다.
람다 함수도 템플릿 형태로 선언될 수 없다. 미래의 C++ 문법에서는 이 정도 한계는 없어질 수도 있지 않겠나 하는 생각이 든다.

3. 전처리기에서 sizeof 연산자 떡밥

전처리기의 #if 조건식에서는 어지간한 정수 연산이 가능한 반면, 잘 알다시피 sizeof 연산자는 사용할 수 없다. sizeof의 값에 따라 곧장 조건부 컴파일이 가능하면 참 좋겠지만 그건 순리에 어긋나는 일이다. 컴파일러에게 넘겨 줄 코드를 전처리 하는 중인데, 임의의 구조체의 크기처럼 소스 코드를 컴파일해야만 값을 구할 수 있는 연산자를 전처리기에다가 요구할 수는 없기 때문이다.

과거에 C++에서 컴파일러와 링커 사이의 순리를 거스르는 시도를 하다 템플릿 export 흑역사가 발생했듯, sizeof도 전처리기와 컴파일러 사이에서 "닭이 먼저냐 계란이 먼저냐" 싸움이 될 수 있다.
전처리기를 이용한 조건부 컴파일러는 소스 코드의 이식성에 도움이 되는 기능인데, 정작 기계 종속적인 이식성 관련 정보를 무작정 표준 매크로 심벌로 집어넣을 수는 없다니 이거 참 역설이 아닐 수 없다.

물론 int나 void*의 크기 정도야 전처리기에다가 곧장 박아 넣을 수도 있다. 그러나 근본적으로 전처리기는 코드의 의미나 타겟 플랫폼 따위는 전혀 모른 채 시종일관 lexical한 치환만 하면서 완전 동일하게 동작하는 물건이며, 포인터 같은 기계 종속적인 사항에 대해서는 완전히 아오안이어야 한다.

이런 이유로 인해 포인터의 크기나 비트 endian-ness 같은 타겟 플랫폼 정보는 있으면 유용할 것 같음에도 불구하고 표준 predefined 매크로로 제공되지 않는다. 조건부 컴파일을 위해 그런 정보가 필요하다면 필요한 사람이 해당 매크로 심벌을 별도의 헤더나 컴파일러 옵션을 통해 지정해 놔야 한다. 전처리기가 월권 행위를 저지르지 않아도 되게 해 주기 위해서이다.

다만, 전처리기가 아닌 컴파일러의 입장에서는 sizeof는 컴파일 타임 때 값이 결정되는 아주 static하고 깔끔한 연산자이다. sizeof의 피연산자로는 타입 이름과 값이 모두 올 수 있는데, 값은 '직접 실행이 되지 않는다'. 쉽게 말해 sizeof( *((int *)NULL) )을 해도 뻑이 안 난다는 뜻.
그렇기 때문에 sizeof의 값은 static 배열의 크기를 결정하는 데 쓰일 수 있고, 또 case 문이나 템플릿의 정수값 인자에도 얼마든지 들어갈 수 있다.

Posted by 사무엘

2015/08/31 08:39 2015/08/31 08:39
,
Response
No Trackback , 6 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1133

C++의 클래스에는 여느 객체지향 언어와 마찬가지로, 정적 바인딩이 아닌 실행 시간 동적 바인딩을 거쳐 호출되는 가상 함수라는 개념이 있다.
일반 함수들은 선언만 해 놓고 정의를 안 했다면, 그 함수를 실제로 호출한 곳이 있을 때만 링크가 시도되고 링크 에러가 난다. (물론 DLL을 만들 때처럼 외부로 export된다고 명시된 함수라면, 우리 모듈 내부에서 당장 안 쓰이더라도 당연히 몸체가 존재해야 함. 이건 논외로 한다.)

이런 일반 함수와는 달리 가상 함수는 해당 클래스에 속하는 개체가 생성되어 가상 함수 테이블이 만들어질 때 이미 참조가 발생한다. 그렇기 때문에 일단 선언을 한 이상, 코드 내부에서 직접적으로 호출을 안 하더라도 반드시 정의가 돼 있어야 한다. 그래야 링크 에러가 안 난다.
가상 함수이지만 자기 몸체가 존재하지 않아도 되는 예외적인 경우는 단 하나, 순수 가상 함수뿐이다.

순수 가상 함수가 존재하는 클래스는 반쯤은 불완전한 타입과 비슷하다. 불완전하기 때문에 그 클래스의 인스턴스를 직접 만들 수 없다. 누군가가 얘를 상속하여 파생 클래스를 만든 뒤, 모든 순수 가상 함수들에 대한 몸체를 구현해 줘야만 한다. 몸체를 안 만들고 선언만 해 놓으면 컴파일은 되지만 그제서야 링크 에러가 나게 된다. 즉, 순수 가상 함수는 가상 함수의 링크와 관련된 문제를 파생 클래스로 보류하는 역할을 한다.

순수 가상 함수가 들어있는 추상 클래스의 인스턴스를 직접 만드는 것은 컴파일러에 의해 원천 봉쇄되고 금지되어 있다. 그런데 아주 특수한 상황에서는 이렇게 몸체가 없는 추상 클래스에 대한 직접 호출이 일어날 때가 있다. 예를 들어 아래와 같은 경우이다.

class CDerived;
class CBase {
    CDerived *m_pt;
public:
    CBase(CDerived *p);
    virtual void Pure() = 0;
};

class CDerived: public CBase {
public:
    CDerived(): CBase(this) {}
    void Pure() { puts("Ahh"); }
};

CBase::CBase(CDerived *p): m_pt(p) { p->Pure(); }

int main() { CDerived obj; }

생성자에서 자기 자신이 아직 완전히 초기화되지 않은 상태일 때 Pure()을 호출해 버리니, 마치 열차가 탈선한 것처럼, 지뢰찾기에서 지뢰를 밟은 것처럼 허를 찔리는 것이다.
사실 forward 선언까지 동원해 가면서 굳이 m_pt를 CDerived의 포인터로 지정하지 않아도 된다. 그냥 기반 클래스와 동일한 CBase *로 지정해도 에러가 나는 건 마찬가지이다.

참고로, CBase의 생성자에서 저렇게 번거롭게 p->Pure()을 하지 않고 그냥 this에 대해서 Pure()을 호출하려 시도하면 그건 순수 가상 함수임에도 불구하고 동적 바인딩이 아니라 정적 바인딩이 일어난다. CBase에 Pure 함수가 정의돼 있지 않다고 링크 에러가 난다. 그걸 별도의 포인터 값으로 억지로 우회함으로써 아직 몸체가 없는 순수 가상 함수에 도달할 수 있다. 위의 코드는 굉장히 인위적인 예이지만, 멀티스레드 race condition 같은 데서도 드물게 이런 일이 벌어질 수 있다.

C#은 생성자에서도 동적 바인딩이 잘 처리되는 게 보장되는 반면, C++은 옛날에 만들어지기도 해서 성능 보전을 위해서인지 설계 관점이 다소 보수적이다. 기반 클래스의 생성자는 자신이 파생 클래스의 생성 과정의 일부라 하더라도 파생 클래스에 대한 단서를 전혀 얻을 수 없고 언제나 기반 클래스의 형태로만 동작한다.
여러 모로 생성자에서 가상 함수를 호출하는 건 좋지 않다는 걸 알 수 있다. DllMain 안에서 또 DLL을 로딩하지 말고 가능한 한 거기서는 몸을 사리고 있어야 하는 것과 비슷한 맥락이다.

초기화가 되기 전에 가상 함수 테이블이 가리키는 주소값이 NULL이었다면, 그냥 더 생각할 것도 없이 운영체제 차원에서 '잘못된 연산' 에러가 나고 그걸로 끝이 날 것이다. 그러나 그렇게만 하는 건 재미(?)가 없고 데이터를 조작하다가 발생한 여느 null pointer 에러와 구분도 어려우니 컴파일러는 가상 함수 테이블에다가 순수 가상 함수에 대한 디폴트 핸들러의 주소를 넣어 준다. 주소를 한 번만 넣어 주면 끝이니 딱히 성능에 영향을 받을 일이 없고, 따라서 debug뿐만 아니라 release 빌드에도 못 넣을 이유가 없다.

개체가 정상적으로 초기화됐다면 그 주소는 사용자가 작성한 파생 클래스의 함수로 당연히 대체된다. 그러나 그게 아직 바뀌지 않았다면 핸들러 함수로 간다. 얘는 assert(0)과 비슷한 급의 예외를 발생시키는 것 말고 딱히 하는 일은 없다. 다만, 이 일이 벌어졌을 때 사용자가 지정해 준 함수가 호출되게 할 수는 있다.

가상 함수가 몸체가 온전하지 않으면 컴파일 에러(순수 가상 함수에 대한 클래스 선언)와 링크 에러(...)뿐만 아니라 이렇게 런타임 에러까지 드물게나마 발생할 수 있다는 걸 알 수 있다. 런타임 에러라니 왠지 C++답지 않아 보이지만, 이건 그래도 운영체제나 컴퓨터 차원으로 갈 런타임 에러를 언어 시스템 차원에서 수습 가능한 런타임 에러로 바꿔 주는 체계까지는 갖춰져 있다. 배열 첨자 초과나 division by 0 에러는 전자로 바꾸려면 매번 값을 사전에 점검해야 하기 때문에 오버헤드가 큰 반면, 순수 가상 함수 호출은 그냥 디폴트 함수 주소만 설정해 주면 되니까 말이다.

Posted by 사무엘

2015/08/26 08:34 2015/08/26 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1131

네트워크나 파일 같은 외부 입출력을 열고 닫는 작업은 실패할 가능성이 워낙 높기 때문에 프로그램 작성에서 에러 처리가 거의 무조건적인 필수이다.

하지만 메모리 할당은 그렇지 않다. 수~수십 MB 정도 공간을 요청하거나 재할당하는 것쯤은 요즘 컴에서는 실패할 가능성이 0에 수렴한다. malloc의 결과값이 NULL인지 일일이 체크하는 코드는 요즘 거의 찾을 수 없다. C++의 new 연산자도 예전에는 실패 시 NULL 리턴이었지만 지금은 예외를 던지는 형태로 디자인이 바뀐 지 오래다. 그거 일일이 NULL 체크를 하는 건 너무 남사스럽고 성가시고 민망하기 때문이다.

메모리 할당을 위해 어지간해서는 C++의 깔끔한 new와 delete를 쓴다지만, C++ 연산자에는 메모리의 재할당 기능이 없기 때문에 이를 위해서는 여전히 malloc/realloc/free 쌍이 쓰인다. 그리고 좀 원시적인 테크닉이긴 하지만 가변 길이 구조체의 메모리 할당을 위해서도 크기 지정이 자유로운 C 스타일의 메모리 함수가 필요하다. 아니면 operator new 함수를 직접 호출하든가 말이다.

그런데 realloc은 실행이 실패했을 때의 상태가 꽤 복잡하다. 보통 ptr=realloc(ptr, newsize) 같은 형태로 활용을 하는데, 재할당이 실패했다고 생각해 보자. 이때는 realloc은 재할당을 할 수 없어서 NULL을 되돌린다. 이는 분명 비정상적인 오류 상황이고 프로그램이 그에 대한 별도의 대비를 하긴 해야 하지만, 그렇더라도 ptr이 원래 가리키던 메모리는 아무 이상이 없다. 그런데 ptr에다가 무턱대고 마치 malloc의 리턴값처럼 NULL을 대입해 버리면 ptr은 소실되고 메모리 leak이 발생하게 된다.

그러니 실행이 실패하더라도 메모리 leak은 발생하지 않게 하려면

ptr_tmp=realloc(ptr, newsize);
if(ptr_tmp) ptr=ptr_tmp; //성공
else { } //실패

번거롭지만 이렇게 임시 포인터 변수를 하나 추가로 둬서 실행이 성공했을 때에만 포인터의 실제값을 반영하게 해야 안전하다. 본인은 이 점을 한 번도 생각을 안 하고 있었는데 비주얼 C++ 2012에서부터 추가된 코드 정적 분석기가 지적을 해 주는 걸 보고서야 “아하!”하고 무릎을 쳤다.

이런 것을 생각하면 realloc의 실패야말로 리턴값보다는 예외 처리로 알려 주는 게 더 편리하겠다는 생각이 든다.
절차형으로 실행되는 컴퓨터 프로그램에서는, 당연한 말이지만 n+1단계 명령은 그 앞의 1~n단계의 모든 명령들이 성공적으로 차곡차곡 잘 실행됐다는 전제하에서만 실행 가능하다. 중간에 뭔가 탈이 났다면 더 진행을 할 수 없으며 어디까지 앞뒤로 되돌아가면 되는지를 컴퓨터가 스스로 판단할 수 없다. 컴퓨터에게는 인간 같은 유도리가 존재하지 않는다. 그렇기 때문에 그런 정보가 없다면 그 프로그램은 전체가 강제 종료되는 것밖에 답이 없다.

자동차 운전을 하는 사람이라면 단순히 핸들과 페달과 변속기를 조작하는 것 말고도 사고가 났을 때의 대처 요령과 보험사 연락처 같은 것도 숙지하고 있어야 하듯, 컴퓨터 프로그램도 마찬가지이다. 중간에 탈이 나도 최대한 부드럽게 수습하고, 피치 못할 상황에서  프로그램이 죽더라도 최소한 지금 작성 중인 문서를 저장이라도 한 뒤에 죽는 그 로직 자체도 프로그래밍이 돼 있어야 한다. 그것이 바로 예외 처리라는 분기 제어에 해당한다.
아울러, 숙달된 프로그래머라면 예외 처리를 구현하는 데 드는 추가 오버헤드와 비용을 숙지해 둘 필요도 있다. 수많은 객체들의 생명 주기를 관리하면서 여러 함수들을 한꺼번에 이탈하는 것도 그냥 될 리는 없으니 말이다.

C/C++은 애초에 운영체제/하드웨어 차원에서의 crash는 있어도 언어 차원에서의 예외 처리라는 게 아예 존재하지 않던 언어이다 보니 이쪽의 지원이 다른 언어들보다 상대적으로 미비하다. C++에 try/catch 키워드는 한참 뒤에 등장했으며 언어 자체는 이 예외 구문을 전혀 사용하지 않는다. 이걸 사용하는 건 라이브러리 계층에서이다. 그리고 예외 처리용 객체를 날려 줄 때조차도 new로 메모리를 할당했다면 해제를 수동으로 해 줘야 하니 불편한 점이 아닐 수 없다.

다시 본론인 realloc 얘기로 돌아온다.
저런 예외 처리도 오버헤드가 크니 싫고 리턴값만으로 모든 책임을 회피하고 싶다면, realloc 함수의 프로토타입을 차라리 이렇게 설계했으면 더 편했을지도 모른다.

bool realloc(void **pptr, size_t newsize);

void **라니 참 COM스러워 보이지만(CoCreateInstance, IUnknown::QueryInterface ㅋㅋ), C++이라면 템플릿 함수로 이걸 감싸서 지저분함을 한결 예방할 수 있을 것이다.

if(realloc((void **)&ptr, newsize)) { /* 성공 */ }
else { /* 실패 */ }
free(ptr);

내가 무엇을 의도하는지는 딱 보면 알 수 있을 것이다. 기존 메모리를 가리키고 있는 포인터의 주소를 받아서, 재할당이 성공하면 그 포인터가 가리키는 값을 그대로 바꿔 버리고 true를 되돌리는 것이다. 어차피 지금 realloc 함수는 ptr=realloc(ptr, newsize)라고 ptr이 함수 인자(input) 겸 리턴값(output) 형태로 동시에 쓰이고 있으며, 재할당이 성공했다면 예전 주소는 보관하고 있을 필요가 전혀 없으니 말이다.

실패했다면 ptr은 *ptr이든 **ptr이든 아무 변화가 없고 리턴값만 false가 된다. free(ptr)을 해 주는 한 어떤 경우든 메모리 leak 걱정은 안 해도 된다. realloc 함수가 이렇게 만들어지는 게 더 낫지 않았나 싶은 생각이 든다.
뭐, realloc이 결코 실패하지 않는다고 가정하고 프로그램이 막무가내로 동작한다면, 차라리 NULL 포인터 일대를 액세스하다가 확실하게 죽는 게 기존 메모리를 범위를 초과하여 건드리다가 죽는 것보다는 더 안전할지도 모르겠지만 말이다.

끝으로 하나 더. fopen에서 접근 모드와(read/write 등) 데이터 처리 모드(바이너리/텍스트) 인자는 들어올 수 있는 조합이 뻔하고 상수 명칭 조합으로 처리해도 하등 이상할 게 없을 텐데, 왜 하필 더 파싱도 어렵게 문자열을 쓰고 있는지도 이유를 모르겠다. 딱히 확장의 여지가 있어 보이지도 않는데 굳이 _open 같은 저수준 함수와 형태를 달리할 이유가 없다. 이런 것들이 C 라이브러리에 대해서 궁금한 점이다.

Posted by 사무엘

2015/04/14 08:25 2015/04/14 08:25
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1083

1. elseif 키워드

프로그래밍 언어에 따라서는 else if를 한데 묶은 축약형인 elseif 또는 elif 키워드를 별도로 제공하는 경우가 있다.
베이직이나 파이썬, 그리고 프로그래밍 요소 중에 없는 게 없는 백과사전형 언어인 Ada에는 저게 있다.

하지만 파스칼, C/C++이나 그 파생형 언어들은 전통적으로 그게 없다. 굳이 그걸 또 제공할 필요 없이 기존 if/else만으로도 동일한 표현력과 계산 능력 자체는 낼 수 있으며,
또한 더 큰 이유로는, 이들 언어는 안 그래도 공백이나 줄바꿈에 구애를 받지 않는 freeform 문법이기 때문이다. 필요하다면 어차피 else if를 한 줄에 나란히 연달아 써도 elseif와 얼추 비슷한 비주얼을 만들 수 있다. (컴파일러의 구문 분석 스택은 복잡해지겠지만..) 베이직과 파이썬은 그렇지 않다.

elseif 축약형은 else 절에서 실행되는 구문이 다음 if 절에 '완전히' 포함되어 있을 때 유용하다.
원래는 else 다음에 소스 코드의 들여쓰기가 한 단계 증가해야 하지만 그렇게 하기는 귀찮고..
수평적인 들여쓰기 단계에서 여러 개의 if를 대등한 위상에서 마치 switch-case처럼 늘어놓고 싶을 때 elseif가 쓰인다.

이런 점에서 보면 elseif 축약은 if-else에 대해서 tail-cut recursion을 한 것과 개념적으로 유사하다.
함수 재귀호출 뒤에 또 다른 추가적인 계산이 없다면, 그런 단순 재귀호출 정도는 스택을 사용하지 않는(= 한 단계 깊이 들어가는) 단순 반복문으로 바꾸는 것 말이다.

사실 C/C++은 elseif 축약이라는 개념은 언어 자체엔 없고 전처리기에만 #elif라는 형태로 있다.
전처리기는 알다시피 freeform 문법이 아니기 때문에 elif 없이 else와 if를 동시에 표현하려면 얄짤없이 줄 수가 둘로 늘어나야 하니,
문법을 최대한 간단하게 만들고 싶어서 부득이 그런 지시자를 넣은 것 같다.

2. NULL 포인터와 0

하루는 통상적으로 사용하던 #define NULL을 0에서 nullptr로 바꾸고 날개셋 코드를 리빌드해 봤다. 그랬더니.. 생각지 못했던 곳에서 엽기적인 컴파일 에러가 떴다.

아니 내가 머리에 총 맞았었나.. 왜 bool 변수에다가 NULL을 대입할 생각을 했지? =_=;;
HRESULT 리턴값에다가 S_OK 대신에 return NULL을 해 놓은 건 도대체 뭔 조화냐.
그리고 그 정도는 애교고.. obj=NULL이 원래는 컴파일 에러가 났어야 했는데 잘못된 코드를 생성하며 지나쳐 버리는 경우가 있었다. 포인터를 별도의 클래스로 리팩터링하는 과정에서 실수가 들어간 것이다.

그 클래스가 정수 하나를 인자로 받는 생성자가 있기 때문에 obj=Class(0)으로 자동으로 처리되고 넘어갔는데, 그 클래스는 독자적인 메모리 할당이 있으면서 대입 연산자 같은 것도 별도로 존재하지 않았다.
이런 일을 막으려고 C++엔 나중에 생성자에 explicit이라는 속성을 지정하는 키워드가 추가되었지만 그걸 사용하지 않는 레거시 코드를 어찌할 수는 없는 노릇이고..

아무튼 언어에서 type-safety를 강화하는 게 이렇게 중요하다는 걸 알 수 있었다.
Windows 플랫폼 헤더 include에서 NULL의 definition이 nullptr로 바뀌는 날이 언제쯤 올까? 옛날에 16비트에서 32비트로 넘어갈 때는 핸들 타입에 대한 type-safety를 강화하면서 STRICT 상수가 도입된 적이 있었는데.

NULL은 C 시절에 (void *)0, 초창기 C++에서는 타입 오버로딩 때문에 불가피하게 그냥 0이다가 이제는 nullptr로 가장 안전하게 변모했다.
개인적으론, PSTR ptr = false; 도 컴파일러 차원에서 안 되게 좀 막았으면 좋겠으나.. 포인터에 0상수 대입은 뭐 어찌할 수 없는가 보다.

3. 자바의 문자열

자바(Java)로 코딩을 하다 보면 나처럼 C++ 사고방식에 머리가 완전히 굳은 사람의 관점에서 봤을 때 궁금하거나 불편하다고 느껴지는 점이 종종 발견된다.
int 같은 기본 자료형이 아니면 나머지는 모조리 클래스이다 보니 한 함수에서 데이터 참조용으로나 잠깐 사용하고 마는 int - string 쌍 같은 것도 못 만드는지? 그런 것도 죄다 새 클래스로 만들어서 new로 할당해야 하는지?

그리고 기본 자료형은 값으로만 전달할 수 있으니 int의 swap 함수조차 만들 수 없는 건 너무 불편하지 않은지?
인클루드가 없는데 자신 외의 다른 클래스에 존재하는 public static final int값이 switch case 상수로 들어오는 게 가능한지? 등등..

이와 관련되어 문자열은 역시 자바 언어에서 좀 어정쩡한 위치를 차지하며 특이하게 취급되는 물건이다.
얘는 일단 태생은 기본 자료형이 아닌 객체/클래스에 더 가깝다. 그래서 타입의 이름도 소문자가 아닌 대문자 S로 시작하며, 이 개체는 가리키는 게 없는 null 상태가 존재할 수 있다.

그러나 얘는 문자열 상수의 대입을 위해서 매번 new를 해 줘야 하는 건 또 아니다. 이건 예외적으로 취급되는 듯하다.
그럼 그냥 String a; 라고만 하면 얘는 길이가 0인 빈 문자열인가(""), 아니면 null인가? 그리고 지역 변수일 때와 클래스 멤버 변수일 때에도 그 정책이 동일한가? 뭐 직접 회사에서 프로그램을 짜 본 경험으로는 전자인 것 같긴 하다.

단, 자바의 문자열을 다룰 때는 주의해야 할 점이 있다. 자바 프로그래머라면 이미 잘 숙지하고 계시겠지만, 문자열의 값 비교를 ==로 해서는 안 된다는 것이다. equals라는 메소드를 써야 한다.
==를 쓰면? C/C++식으로 얘기하자면 문자열이 들어있는 메모리 포인터끼리의 비교가 돼 버린다. 애초에 포인터의 사용을 기피하고 다른 걸로 대체하는 컨셉의 언어에서, 이런 동작은 99% 이상의 경우는 프로그래머가 의도하는 결과가 아닐 것이다.

C++에서야 문자열 클래스에 == 연산자가 오버로딩되지 않은 경우가 없을 테니 언어가 왜 저렇게 만들어졌는지 이해하기 어렵겠지만.. 자바는 연산자 오버로딩이란 게 없는 언어이며 String은 앞서 말했듯이 기본 자료형과 클래스 사이의 어중간한 위치를 차지하는 물건이기 때문에 이런 디자인의 차이가 발생한 듯하다. 자바는 안 그래도 걸핏하면 클래스 새로 만들고 get/set 등 다 메소드로 구문을 표현해야 하는 언어이니까.
오죽했으면 본인은 회사에서 자바 코드를 다루면서도 문자열 비교를 실수로 ==로 잘못 해서 발생한 버그를 발견하고 잡은 적도 있었다.

그나저나 유사 언어(?)인 스칼라, 자바스크립트 같은 언어들은 ==로 바로 문자열 비교가 가능했던 걸로 기억한다.

4. true iterator

파일을 열어서 거기에 있는 문자열을 한 줄씩 얻어 오는 함수(A), 그리고 각 문자열에 대해 출력을 하든 변형을 하든 일괄적인 다른 처리를 하는 함수(B)를 완전히 분리해서 별도로 작성했다고 치자. 혹은 한 디렉터리에 파일들을 서브디렉터리까지 빠짐없이 쭉 조회하는 함수(A)와, 그 찾은 파일에 대해서 삭제나 개명 같은 처리를 하는 함수(B) 구도로 생각할 수도 있다.
그런데 이 둘을 연계시켜서 같이 동작하게 하려면 어떻게 하는 게 좋을까?

이럴 때 흔히 떠올릴 수 있는 방법은,
A 함수에다가 B 함수까지 인자로 줘서 호출을 한 뒤, A의 내부 처리 loop에서 B에 넘겨줄 데이터가 준비될 때마다 B를 callback으로 호출하는 것이다. B는 간단한 일반 함수 + context 데이터 형태가 될 수도 있고, 아니면 가상 함수를 포함한 인터페이스 포인터가 될 수도 있다.

데이터 순회를 하는 A 자체도 파일을 열고 닫거나 내부적으로 재귀호출을 하는 등 state가 존재하기 때문에 매번 함수 실행을 시켰다가 종료하기가 곤란한 경우, 상식적으로 A를 먼저 실행시킨 뒤에 A가 계속 실행되고 있는 중(= 상태도 계속 유지되고)에 그 내부에서 B를 호출하는 게 바람직한 게 사실이다.
물론, 반복문 loop을 B에다가 두고, 반대로 B에서 A를 callback 형태로 호출하는 것도 불가능한 건 아니다. 그런데 프로그래밍 언어에 따라서는 이런 B 중심적인 사고방식의 구현을 위해 좀 더 획기적인 기능을 제공하는 물건도 있다.

def func():
    for i in [1,5,3]:
        yield i

a=func()
print a.next()
print a.next()
print a.next() # 예상하셨겠지만 1, 5, 3 순서대로 출력

파이썬에는 함수에 return 말고 yield 문이 있다. 그러면 얘는 함수 실행이 중단되고 리턴값이 지정되기는 하는데..
다음에 그 함수를 실행하면(정확히는 next() 메소드 호출 때) 처음부터 다시 실행되는 게 아니라, 예전에 마지막으로 yield를 했던 곳 다음부터 계속 실행된다. 예전의 그 함수 호출 상태가 보존되어 있다는 뜻이다.

난 이걸 처음 보고서 옛날에 GWBASIC에 있던 READ, DATA, RESTORE 문과 비슷한 건가 싶었는데.. 저건 당연히 GWBASIC을 아득히 초월하는 고차원적인 기능이다. C++이었다면 별도의 클래스에다가 1, 5, 3 static 배열, 그리고 현재 어디까지 순회했는지를 가리키는 상태 인덱스 정도를 일일이 구현해야 했을 텐데 저 iterator는 그런 수고를 덜어 준다.

단순히 배열이 아니라 binary tree의 원소들을 prefix, infix, postfix 방식으로 순회한다고 생각해 보자.
순회하는 함수 내부에서 다른 콜백 함수를 호출하는 게 아니라 매번 원소를 발견할 때마다 리턴값을 되돌리는 형태라면..
구현하기가 굉장히 까다로울 것이다. 스택 메모리를 별도로 할당한 뒤에 재귀호출을 비재귀 형태로 일일이 구현해 주거나, 아니면 각 노드에다가 부모 노드의 포인터를 일일이 갖춰 줘야 할 것이다.

C++의 map 자료형도 내부적으로는 RB-tree 같은 자가균형 dynamic set 자료구조를 사용하는데, 이런 iterator의 구현을 위해서 편의상 각 노드에 부모 노드 포인터를 갖고 있는 걸로 본인은 알고 있다. RB-tree는 내부적으로 로직이 굉장히 복잡하고 까다로운 자료구조이긴 하지만, 그래도 부모 노드 없이도 구현이 불가능한 건 아닌데 말이다.
안 그랬으면 iterator가 자체적으로 스택을 멤버 변수로 갖거나, 최소한 메모리 할당· 해제를 위해 생성자나 소멸자까지 갖춰야 하는 복잡한 class가 돼야 했을 것이다. 어떤 경우든 포인터 하나와 비슷한 급인 lightweight 핸들이 될 수는 없다.

개인적으로는 지난 여름에 <날개셋> 한글 입력기 7.5에 들어가는 새로운 한글 입력 순서 재연 알고리즘을 구현할 때 비슷한 레벨의 iterator를 비재귀적으로 구현한 적이 있는지라, yield문의 의미가 더욱 절실히 와 닿는다.

Posted by 사무엘

2015/02/25 08:38 2015/02/25 08:38
, , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1066

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

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2020/05   »
          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:
1381950
Today:
480
Yesterday:
571