dev.log2009. 9. 7. 02:39
대략적으로, VC++에서 구조체 멤버의 배치와 관련된 개념은 2가지. #pragma pack, 혹은 컴파일러의 /Zp 옵션으로 지정하는 패킹과, __declspec(align)으로 지정하는 정렬이 있다.

원칙적으로는 SSE명령이나 캐쉬에 적합성을 높이기 위한 메모리 배치는 __declspec(align)으로 하는 것이 맞다. 그런데 문제는 __declspec(align) 속성을 가진 벡터 객체, 혹은 이를 포함하는 클래스의 객체가 STL 컨테이너에 들어갈 수 없다는 것이다. 이는 다음 두가지 사실에서 기인한다.
1. VC++컴파일러는 __declspec(align) 속성을 가진 객체를 함수의 밸류타입 인자로 넘길 수 없게 구현돼 있다.
2. STL컨테이너들의 기본생성자는 초기화 과정에서 resize메소드를 호출하는데, 표준에 의하면 resize 메소드는 밸류 타입으로 인자를 받도록 되어 있다.
그러한 고로, 주소 정렬이 포함돼 있는 클래스의 객체는 STL컨테이너에 넣을 수가 없다는 당혹스러운 결과에 직면하게 된다.

전에 회사에서 쓰던 엔진은 vector3 클래스의 선언부를 #pragma pack(16)으로 감싸놓고 할당자만 재정의해서 쓰고 있었다. 요 벡터 클래스가 심심하면 뻗을 때가 있었는데, 대개의 경우는 객체의 주소가 16바이트 정렬이 안된 채로 생성이 되어 SSE 내장 함수에서 뻗어버리는 것이었다. 이때의 경험으로 미루어 안될거 같다는 생각을 하면서도, 집에서 놀던 차에 잉여력을 활용하여 둘 간의 차이점에 대해서 삽질을 좀 해봤다.

사용한 선언은,
#pragma pack( push, 16 )
struct test_pack
{
    float x;
    float y;
    float z;
};
#pragma pack(pop)

struct __declspec(align(16)) test_align
{
    float x;
    float y;
    float z;
};
이렇게 해놓고서 배열을 선언해서 비교해 보았다.
    FILE* out = fopen( "align_pack.txt", "w");
#define LOG_OFFSET(tag,member) fprintf( out, "offset of "  #tag "::" #member " = %d\n", offsetof( tag, member ) );
#define LOG_SIZE(tag) fprintf( out, "size of "  #tag " = %d\n", sizeof( tag ) );
#define LOG_ADDR(pointer) fprintf( out, "address of "  #pointer " = 0x%x\n", pointer );
    LOG_OFFSET( test_pack, x );
    LOG_OFFSET( test_pack, y );
    LOG_OFFSET( test_pack, z );
    LOG_SIZE( test_pack );

    test_pack tp[3];
    LOG_ADDR( &tp[0] );
    LOG_ADDR( &tp[1] );
    LOG_ADDR( &tp[2] );

    LOG_OFFSET( test_align, x );
    LOG_OFFSET( test_align, y );
    LOG_OFFSET( test_align, z );
    LOG_SIZE( test_align );

    test_align ta[3];
    LOG_ADDR( &ta[0] );
    LOG_ADDR( &ta[1] );
    LOG_ADDR( &ta[2] );

    fclose( out );
결과는,
offset of test_pack::x = 0
offset of test_pack::y = 4
offset of test_pack::z = 8
size of test_pack = 12
address of &tp[0] = 0x12fecc
address of &tp[1] = 0x12fed8
address of &tp[2] = 0x12fee4
offset of test_align::x = 0
offset of test_align::y = 4
offset of test_align::z = 8
size of test_align = 16
address of &ta[0] = 0x12fe90
address of &ta[1] = 0x12fea0
address of &ta[2] = 0x12feb0


패킹으로 선언된 test_pack의 경우는 주소 자체가 16바이트 정렬이 안되기 때문에, 패딩 바이트를 주더라도 SSE나 캐쉬라인에 맞추기 위한 목적으로는 쓰일 수 없다는 것이 분명해졌다. 구조체 멤버를 다른 타입으로 (char, short 등) 몇가지 더 추가하면서 알아낸 바로는, #pragma pack으로 지정하는 패킹은 멤버들의 구조체 내에서의 옵셋에만 영향을 주고, 구조체 자체의 배치에는 영향을 끼치지 않는다는 것.

vector3 클래스를 생성 영역에 따라 분리하는 것도 방법. 힙에서 생성할 객체들은 할당자에 의해 정렬이 되는 클래스로 분리하고, 스택이나 정적 데이터 영역에 생성할 객체들은 __declspec(align)으로 분리한다는 것인데... 이럴 경우에도 STL의 메소드가 내부적으로 사용하는 임시 객체가 정렬이 안이루어지면 뻗을 수밖에 없다.

이제 vector3 클래스를 SSE를 활용하면서 컨테이너에 넣기 위한 방법은 1가지밖에 없다는 결론이 도출된다. 바로 사용하고자 하는 모든 컨테이너 클래스의 메소드를 밸류 타입으로 인자를 받지 않도록 상수 참조의 형태로 바꿔서 재정의한다는 것. 대부분의 STL컨테이너를 커스터마이징해야 한다는 얘기. 이건 삽질도 보통 삽질이 아닌데.....


Posted by uhm
geek.log2009. 2. 2. 00:57
블로그 유입경로를 보다 보니 이런게 눈에 띄었다.


어허허허허허허

물론, deque는 dequeue와 동일한 대상을 가리키는 것이 맞지만, 사전을 찾아보면, deque의 발음은 [데크]이고, dequeue의 발음이 [디큐]이다. 그리고, STL에서 double-ended queue를 구현할때는 이름을 dequeue라 붙이지 않고, deque라 붙였으므로, 우리는 [데크]라고 읽는 것이 맞지 않을까 싶다.

지금까지 본 유사한 예로

height를 [헤이트]라고 읽은 k군,
enum을 [에넘]이라고 읽은 l씨,
등이 생각난다.

사전 한번만 찾아보면 되는 것을, 자기가 알고 있는게 맞다는 굳은 신념때문에 안 찾아보기 때문일지도 -_-a


Posted by uhm
dev.log2008. 2. 1. 04:51

DLL에서 익스포트할 클래스에서 STL 클래스 멤버를 쓰는 것은 바람직하지 않지만, 세상사란 그리 만만하지 않은 법. DLL에서 STL클래스를 익스포트 해야 할 경우가 가끔 생기는데, 사실 vector같은 건 별로 문제가 되지 않는다. 컨테이너 자체가 간단하고, 연관된 할당자도 별로 없기 때문에. 사실 map이나 set같은 복잡한 컨테이너가 진짜 문제다.

 

웹에서 찾아볼 수 있는, STL map을 DLL에서 익스포트하기 위한 매크로는 대개 다음과 같다

#define EXPORT_STL_MAP( dllmacro, mapkey, mapvalue ) \
  template struct dllmacro std::pair< mapkey,mapvalue >; \
  template class dllmacro std::allocator< \
    std::pair<const mapkey,mapvalue> >; \
  template struct dllmacro std::less< mapkey >; \
  template class dllmacro std::allocator< \
    std::_Tree_ptr<std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> > >; \
  template class dllmacro std::allocator< \
    std::_Tree_nod<std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> > >; \
  template class dllmacro std::_Tree_nod< \
    std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> >; \
  template class dllmacro std::_Tree_ptr< \

    std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> >; \
  template class dllmacro std::_Tree_val< \
    std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> >; \
  template class dllmacro std::map< \
    mapkey, mapvalue, std::less< mapkey >, \
    std::allocator<std::pair<const mapkey,mapvalue> > >;

(이걸보고 주눅이 안들면 비정상. 안심해라)

 

근데 이 매크로를 써서 map을 익스포트하면, VC2003에서는 아무 탈 없이 컴파일 됐는데, VC2005에서는 요상하게 다음과 같은 에러를 뱉었다.

1>c:\dev\visualstudio8\vc\include\xtree(61) : warning C4251: 'std::_Tree_nod<_Traits>::_Alnod' : class 'std::allocator<_Ty>'에서는 class 'std::_Tree_nod<_Traits>'의 클라이언트에서 DLL 인터페이스를 사용하도록 지정해야 합니다.
1>        with
1>        [
1>            _Traits=std::_Tmap_traits<int,short,std::less<int>,std::allocator<std::pair<const int,short>>,false>
1>        ]
1>        and
1>        [
1>            _Ty=std::_Tree_nod<std::_Tmap_traits<int,short,std::less<int>,std::allocator<std::pair<const int,short>>,false>>::_Node
1>        ]
1>        and
1>        [
1>            _Traits=std::_Tmap_traits<int,short,std::less<int>,std::allocator<std::pair<const int,short>>,false>
1>        ]

(보통 STL이 뱉는 에러메시지는 암호같긴 하다)

 

이걸 해석하는 것도 만만치 않긴 하지만, 해석하자면 std::allocator< ... >::_Node 가 익스포트되도록 지정되지 않았다..는 내용. 따라서 다음 두 줄을 매크로 선언에 추가하면 된다. 위의 메시지는 _Node에 대한 것이고, _Node에 대한 할당자를 익스포트하도록 지정하면 또 다른 어딘가에서 _Node*에 대한 할당자도 익스포트해야 한다는 메시지가나온다. 그래서 두줄을 더 추가해야 했다.

 

  template class dllmacro std::allocator< \

    std::_Tree_nod<std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> >::_Node >; \
  template class dllmacro std::allocator< \
    std::_Tree_nod<std::_Tmap_traits<mapkey,mapvalue,std::less<mapkey>, \
    std::allocator<std::pair<const mapkey,mapvalue> >,false> >::_Node* >; \

 2003에서는 멀쩡히 잘 돌아가던 매크로가 2005에서는 안되는 이유가 뭔지는 잘 모르겠지만 -_- 여튼 저렇게 하니까 됐다. 잊어먹지 말자.


Posted by uhm
dev.log2007. 11. 21. 01:05

우리의 GS군이 어제 STL할당자를 만들다가.. rebind때문에 고생을 좀;; (이거 모르는 사람 은근히 많다)
STL할당자는.. 어떤 타입 T에 대해서 할당을 해주도록 되어 있다. 이를테면,

template < class T, class A = std::allocator<T> > class vector;

이런 식이다. 근데 이게 벡터 같은 놈에서는 별 상관 없는데, 리스트나 데크[각주:1], 맵, 셋등 에서는 약간 미묘하다. 이들이 할당하는 건 T가 아니기 때문이랄까. 리스트는 list_node<T> 따위, 맵에서는 tree_node<T> 따위 단위로 할당하기 때문이다. 이 글을 여기까지 읽는 사람이라면, 자료구조 수업시간에 링크드 리스트나 바이너리 서치트리 따위는 다 만들어 봤을 테니, 무슨 소리인지 알 거라고 본다.

따라서 리스트, 맵, 셋 등에서 필요한 할당은 T타입이 아니라, XXXX_node<T>타입에 대한 할당자이다. 어떤 할당자 A가 T를 할당하도록 만들어져 있는 상황에서, 컨테이너한테 A가 주어졌는데, 컨테이너가 필요한 것은 A가 할당하는 T가 아니라 N<T> 타입.

이걸 위해서 STL에는 할당자가 rebind라는 타입을 내부에 정의하도록 되어 있다. rebind는 다른게 아니라, A한테 "T말고 N<T>를 할당하려면 어떤 할당자를 쓰면 좋겠느냐-"라고 물어볼 수 있는 구석을 만들어 놓겠다는 것이다. 그럼 컨테이너에서는 T가 아닌 다른 타입을 (이를테면, N<T>) 할당 할 때는, A에서 정의한 rebind라는 녀석이 지정해 놓은 할당자를 새로 만들어서 그놈한테서 할당받으면 된다는 이야기.

 템플릿 A의 멤버 템플릿 rebind 구조체는 보통 다음과 같이 정의한다.

template < class _Other >
struct rebind
{
    typedef A<_Other> other;
};

컨테이너는 A가 주어지면, A::rebind<XXXX_node>::ohter 를 할당자로 사용해서 작업하면 된다.
이러면 '끗'.


  1. deque를 '디큐' 내지는 '데큐' 라고 읽는 사람도 은근히 많다 -_- 데크는 메모리의 '블럭'단위로 할당하여 이 포인터들을 한개의 테이블에다 저장해 놓는데, 이 테이블을 할당할 때에도 rebind가 쓰인다. [본문으로]
Posted by uhm
dev.log2004. 2. 27. 12:28
C로 프로그래밍을 처음 배울때 누구나 만들어본 프로그램이 있다. 모두들 짐작할 것이다. 바로 그 유명한 "Hello, world" 프로그램이다. 이 프로그램은 원래 커니건 & 리치가 쓴 The C Programming Language에 등장하는 첫번째 예제로 그후 거의 모든 C입문서의 첫번째 예제로 쓰이고 있다. Hello, world 프로그램은 다음과 같은 간단한 코드이다.

#inlcude <stdio.h>

void main()
{
    printf( "Hello, world\n" );
}

이 예제에서는 Hello, world를 출력하기 위해 printf()를 사용하는데, 아는 사람은 알겠지만, printf는 C의 표준 라이브러리 함수중 가장 복잡한 함수이다. printf()의 모든 기능을 완전히 이해하기 위해서는 variable argument와 호출규약 등 언어의 지저분한(?) 하위레벨 기능까지를 알아야 하는 까닭이다. C에서 가장 처음 배우는 예제가 C에서 가장 복잡한 함수를 사용한다는 것은 일종의 아이러니가 아닐까.
사실, 위의 예제에서 문자열을 출력한다는 용도로는 puts()함수를 쓰는 것이 더 적합할 것이다. 그러나 아마도 커니건 & 리치는 출력에 관한한 거의 만능에 가까운 printf()함수를, 사용자에게 친숙하게 만들고자 일부러 썼을 것이다.
C라는 언어안에서만 이야기 한다면, printf()의 가장 큰 약점은 수행속도이다. printf()는 첫번째 인자로 주어지는 형식지정문자열(format specifier)을 파싱하여 그 뒤에 주어지는 인자의 출력형식을 결정하는데, 여기에 걸리는 오버헤드가 만만치 않다. 간단한 문자열 출력이라면 puts등을 사용하는 것이 적절하다. 물론 문자열, 정수, 부동소수 등이 복잡하게 얽힌 형식으로 출력한다면 이야기가 달라지긴 하지만.

그러면 이제 논의를 C++의 세계로 옮겨 보자.
C++의 표준 출력기능은 이제 printf[함수]가 아닌 cout[객체]로 옮겨졌다. 왜 옮겼을까?
printf()가 가지는 가장 큰 약점은 형안정성을 보증하지 못한다는 것이다.

#inlcude <stdio.h>

void main()
{
    printf( "%f", 1 );
}

위 와 같은 코드는 C환경에서 정상적으로 컴파일되며 실행도 된다. 물론 현대적인 컴파일러나, lint같은 툴을 쓴다면 printf()에 주어진 형식지정자와 실제인자의 타입이 일치하지 않는다는 워닝을 내줄 수 있겠지만, 위의 코드가 작동하며, 또한 언뜻 보기에 의미 없는 값을 출력한다는 점은 변하지 않는다.
이는 printf()함수가 실매개변수의 타입과 출력 형식을 별도의 정보로 지정하기 때문이다. 대개의 출력형식은 실매개변수의 타입에 따라 결정된다. int형의 실매개변수는 거의 90%이상 %d형식으로 출력할 것이며, 절대 %f형식으로 출력하려고 하는 사람은 없을 것이다. 이는 타입정보와 형식정보가 실제로는 별개의 정보가 아니며 둘 사이에 상관관계가 있다는 것을 의미한다. 물론 고의적으로 플로팅포인트 타입의 비트패턴을 보기 위해 float타입을 16진수 정수형으로 출력하는 경우도 있으나 대개의 경우 타입에 따라 출력 형식이 정해짐을 주지해야 한다. 그러나 printf()는 밀접한 상관관계가 있는 두개의 정보를 별개의 정보로 지정함으로써 둘 사이에 미스매치가 발생할 경우 그에 대해 어떠한 식별도 할 수 없다는 맹점을 지니고 있다.
C++에서 가장 강화된 것 중의 하나가 타입시스템이라고 말한바 있다. 따라서 C++의 설계당시부터 표준출력을 담당하는 printf()함수의 위와같은 맹점때문에 C++에서 표준출력을 담당할 객체가 cout으로 정해지게 된다.
#inlcude <iostream>

int main()
{
    std::cout << "Hello, world" << std::endl;
    return 0;
}

첫번째 Hello, world와 같은 일을 하는 프로그램의 C++버전이다.
cout 은 printf()와는 달리 형 안정성을 보장한다. cout객체가 작동하는 방식은 연산자 오버로딩에 기초를 두고 있기 때문이다. 무슨 말인고 하니, cout객체가 출력할 수 있는 모든 타입에 대해 <<연산자가 오버로딩 되어 있다는 것이다. 이제 컴파일러가 cout객체와 << 연산자를 통해 출력하라는 문장을 만나면 출력할 대상의 타입에 따라 오버로딩된 <<연산자 중에서 적절한 연산자를 자동으로 골라서 출력해 준다는 의미가 된다. 즉, 타입에 맞는 출력 형식을 컴파일러가 선택하므로 형 안정성이 보장되는 것이다.


#inlcude <iostream>

int main()
{
    std::cout << 1 << 1. << std::endl;
    return 0;
}


위 프로그램은 오해의 여지가 없이 의미있는 결과만을 출력해 낸다. 플로팅포인트 타입을 엉뚱하게 int타입으로 출력한다든가 하는 일은 발생하지 않는다. C++의 표준출력객체 cou은 형식정보와 실제 타입정보를 하나로 묶어 관리함으로써 의도치 않은 동작이 발생하는 것을 미연에 방지할 수 있다.

여기에 더하여 printf()가 가지는 또하나의 맹점은 사용자정의 타입에 대한 지원을 전혀 할 수 없다는 것이다. 어떤 클래스 A의 객체 a가 있을 때, printf( "%a", a )와 같은 식으로 일관성 있는 조작을 할 방법이 전혀 없다. 하지만, cout객체로는 ostream 클래스에 대해 <<연산자만 오버로딩 하면 어떠한 사용자정의 타입이건 내장 타입과 같은 방식으로, 일관성 있게 사용할 수 있다는 점은 매우 중요한 강점이다.


이로써 C++에서 되도록이면 printf()를 쓰지 말아야할 몇가지 이유가 분명해진 셈이다. 물론 몇가지 특수한 상황에서는 printf()류의 함수가 유용할 때가 있으나, 일반적인 출력기능은 이제 cout에게 맡기는 편이 현명하지 않을까.

Posted by uhm
dev.log2004. 2. 3. 14:03
루프는 매우 일반적인 프로그램 제어 구성요소로 어떤 언어든지 루프구조를 지원하고 있다. 심지어는 어셈블리에서조차 LOOP 키워드를 통해서 표현이 가능하다. 루프는 프로그래밍 언어를 처음 배울때 부터 익혀온 워낙 친숙한 개념이라 대부분의 프로그래머는 반복작업을 해야할 때 별 생각없이 루프를 작성하곤 한다. 하지만 조금만 생각을 해보면 어떨까.

가장 흔한 예로, 어떤 배열의 원소를 모두 표준 스트림으로 출력하는 작업을 생각해 보자.
대부분은 다음과 같은 코드를 작성할 것이다.
int a[SIZE] = { 1, 2, 3, 4, 5 }:
for ( int i = 0; i < SIZE; i++ )
print_int( a[i] );


출 력 형태가 아름답지 못한 점은 당분간 신경 쓰지 말고, 코드의 구성방식에 대해서만 생각해 보자. C++에서는 저런 끊임없이 나타나는 단순반복 작업을 위한 알고리즘 템플릿이 마련되어 있다. C++사용자라면 대부분 알고 있겠지만, for_each 알고리듬이 그것이다. for_each를 사용하면 다음과 같이 바뀐다.

int a[SIZE] = { 1, 2, 3, 4, 5 };
for_each( a, a+SIZE, fun_ptr( print_int ) );


for_each 는 말 그대로, 시작 이터레이터로부터 마지막 이터레이터까지 모든 구성요소에 대해 주어진 작업을 수행하는 알고리듬을 구현한 함수이다. 세번째 인자에는 fun_ptr 헬퍼 펑션을 사용하여 print_int 함수를 함수어댑터 객체로 포장하여 사용하였다.
그 런데, 분명히 for_each는 '이터레이터'를 받아들인다고 하였는데, 여기서는 배열이름, 즉 포인터를 사용하였다. 이상하다고 생각하는 독자가 있다면, for_each 알고리듬은 템플릿이라는 점을 다시한번 상기하기 바란다. for_each가 요구하는 이터레이터의 조건은, ++멤버연산자가 정의되어 있고, *(역참조)연산자가 정의되어 있으며, 세번째 인자로 주어지는 함수객체의 첫번째 인자의 타입과 *(역참조)연산자가 리턴하는 타입이 같으면 그것으로 만족이다. 이미 알고 있는 바와 같이, 포인터타입에는 ++연산자와 *연산자가 built-in 연산으로 정의되어 있으며, print_int 함수의 인자 타입 int와 *연산자의 리턴타입이 일치하므로 아무 문제 없이 for_each의 인자로 쓰일 수 있다.

물론 for_each는 C스타일의 배열보다는 STL컨테이너와 함께 쓰일때 더욱 유용하다. 만약 지금까지 아무 생각없이 C++로 for, 혹은 wile루프를 작성하고 있었다면 for_each알고리듬으로 바꿔보는 것은 어떨까. 물론 for_each를 쓰는 것이 어색하고 직관적이지 않을 때도 있다. 그러나 STL 알고리듬을 쓰는 연습을 하면 객체지향적인 사고를 기르는데 도움이 된다. 이미 밝혔듯이, for_each를 비롯한 많은 알고리듬들이 수행할 작업을 지정하기 위해 함수객체를 사용한다. 함수객체는 ()연산자, 즉 function call operator를 오버로딩한 클래스의 객체로서, 함수호출을 캡슐화한 객체를 말한다. 즉, STL알고리듬을 사용하는 연습을 통해 작은것부터 객체로 생각하는 습관을 기르는데 도움이 된다고나 할까, 그런 잇점이 있을 수 있다.

물론, 그보다 더 큰 잇점은 깔끔하고 안정적인 코드를 얻을 수 있다는 것이다. 우리가 아무리 루프를 주의깊게 작성해도 범위를 벗어난다던가, 아니면 무한루프로 빠지는 버그를 만들지 않는다고 장담할 수는 없다. 그러나 STL알고리듬은 이미 검증된 코드이므로 우리가 직접 만드는 루프보다 그러한 문제는 확실히 적어질 것을 기대할 수 있다.
Posted by uhm
dev.log2004. 1. 22. 02:15
이 글은 제가 예전에 제가 활동하고 있는 학교 동아리에 "문자열을 입력받는 12가지 방법"이라는 제목으로 올렸던 내용을 재 편집한 것입니다.


C/C++을 이용한 문제해결의 한 단편을 제시하기 위해 "표준입력으로부터 입력받은 길이를 알 수 없는 문자열 저장하기"라는 아주 전형적인 문제의 예를 들어 보겠다.

C --> C++ 을 배운 표준적인(?) 커리큘럼을 따른 프로그래머라면 표준입력(키보드)으로 문자열을 입력받을 때 다음과 같은 C스타일의 표현은 모두 알고 있을 것이다.

방법1)
char s[LENGTH];
scanf( "%s", s );


누구나 알고 있고, 또한 별 무리없이 원하는 결과를 낸다는 점에서 만족스럽다.
그러나 다음과 같은 면에서 문제가 있다.
1> scanf()함수는 인자로 주어지는 형식지정자(format specifier)을 파싱해야 하는 오버헤드가 따른다.
2> 공백문자가 나타나면 읽기를 중단한다.
3> 형 안정성을 보장받을 수 없다 ( "%s" 대신 "%d"로 오타라도 낸다면?)
4> 그리고, 문자열의 예상되는 크기를 프로그래머가 알고 있어야 한다.

1>,2>, 3> 문제를 해결하기 위해 다른 방법을 고려해 보자.

방법2)
char s[LENGTH];
gets( s );


C 표준의 gets()함수는 문자열을 입력받는 거의 흠잡을데 없는 기능을 제공한다는 면에서는 아주 만족스럽다. scanf()와 같이 형식지정자를 파싱해야 하는 오버헤드도 없으며, 빈칸이 나오더라도 개행문자를 입력할때까지 끊임없이 입력받는다. 그러나 역시 다음과 같은 면에서 만족스럽지 못하다.

"표준입력으로부터의 입력이 문자열 버퍼의 크기를 넘어가는 경우에는 어떤 결과가 따를지 예상할 수 없다."

그렇다면 문제를 해결해 보자. 이 문제는 gets()함수가 버퍼의 크기를 전혀 알지 못한다는 것에서 비롯된다. 그렇다면 버퍼의 길이를 알아야 하는 함수를 사용해 보자.

방법3)
char s[LENGTH];
fgets( s, LENGTH, stdin );


이 fgets()함수는 파일로부터 문자열을 읽어들이는 함수이나, stdin이라는 표준입력에 대응하는 파일포인터를 사용함으로써 표준입력으로부터 문자열을 입력받는데도 사용할 수 있음을 상기하자. fgets()의 두번째 인자로 버퍼의 크기를 줌으로써 버퍼 오버플로우 문제는 해결할 수 있다. 그러나 다음과 같은 문제가 따른다.

"fgets()함수가 리턴되더라도 모든 문자열이 입력된 것인지 알 수가 없다."

이제부터 문제가 복잡해지기 시작한다. fgets()함수는 버퍼의 크기까지만 문자열을 읽기 때문에 단순히 함수가 리턴되었다는 것만으로는 아직 표준입력 스트림에 문자가 남아있는지 알 수가 없다. 따라서 추가적인 로직이 필요해진다.

방법4)
char s[LENGTH];
char *t, *u;
int size = 0;
int len;
do
{
    s[LENGTH-2] = 0;
    fgets( s, LENGTH, stdin );
    len = strlen( s );
    size += len;

    u = malloc( size ); // (1)
    strcpy( u, t ); // ...
    free( t ); // (1)

    strcat( u, s );
    t = u;
} while( len == LENGTH-1 && s[LENGTH-2] != '\n' );


상 당히 복잡해 졌다. 더 깔끔하게 정리할 수도 있겠지만, 어쨋든 간단하기 구현하는 한에서 입력스트림으로부터의 문자열을 모두 저장하기 위한 코드임에는 분명하다. while루프는 차치하고라도, 루프 내부의 코드는 대부분 문자열 버퍼의 재할당을 위한 코드이다. 물론, (1) 부분은 realloc()으로 간단히 사용할수도 있다.

방법5)
char s[LENGTH];
char *t = 0;
int size = 0;
int len;
do
{
    s[LENGTH-2] = 0;
    fgets( s, LENGTH, stdin );
    len = strlen( s );
    size += len;

    t = realloc( size );
    strcat( t, s );
} while( len == LENGTH-1 && s[LENGTH-2] != '\n' );


아 주 약간 정리가 되었다. 그러나, 여전히 루프 자체의 복잡성은 남아 있다. 왜 그럴까? 루프 내의 코드를 살펴보면 크게 두 부분으로 이루어져 있음을 알 수 있다. 입력스트림으로부터 받은 문자열을 임시 버퍼 s에 저장하는 부분이며, 나머지는 임시버퍼로부터받은 문자열을 완성된 문자열로 저장하는 부분이다. 이와같은 문자열 조작의 불편함은 전적으로 C에서의 문자열이 '문자열'이 아니라 '문자배열'이기 때문이다.

위와 같은 문제를 해결하기 위해 그렇다면 이제부터 C++의 세계로 넘어가보자.
C++에서 문자열을 입력받는 것은 위의 논의와 비슷하게 진행된다.

방법6)
char s[LENGTH]
std::cin >> s;


cin 객체는 기본적으로 scanf()와 아주 비슷한 일을 한다. 그러나 여러가지 장점이 있다. 만약 s를 선언할때 잘못하여 int로 썼더라도 cin>>s;라는 문장을 컴파일하는 과정에서 컴파일러가 에러를 잡아주어 형 안정성을 보장해 준다.
하지만 여전히 "공백문자에서 멈춤"문제는 남아 있다. 그렇다면 scanf()에서 gets()로 넘어갈때와 같은 고려를 해보자. 이번에 고려할 수 있는 것은 basic_istream클래스의 getline()메소드이다.
(이제부터는 편의상 std 네임스페이스는 생략하도록 하겠다)

방법7)
char s[LENGTH];
cin.getline( s, sizeof( s ) );


getline() 메소드는 gets()함수와 아주 비슷한 일을 하는 iostream클래스의 메소드이다. 차이점이라면 fgets()함수와 비슷하게 버퍼 사이즈를 인자로 받는 정도뿐이다. 역시 fgets()함수와 마찬가지의 문제점을 지니고 있다고 할 수 있겠다.
그렇다면 fgets()에서 스트림을 모두 비우는 루틴을 고려해 보자.

방법8)
char s[LENGTH];
char *t = 0;
int size = 0;
do
{
    cin.clear()
    cin.getline( s, LENGTH );

    size += strlen( s );
    t = realloc( size );
    strcat( t, s );
} while( cin.fail() );


어떨까? 물론.. (시험해보진 않았지만) 제대로 돌아갈 것 같긴 하다. 당신, 정말로 이걸로 만족하는가? C++은 객체지향의 세계이다. 저기서 객체라고는 cin밖에 쓰이지 않았다.
이런 코드는 C를 배운 다음 C++로 옮겨가려는 사람이 쓰게되는 전형적인 스타일이라고 할 수 있겠다. 즉, C의 코드를 그대로 C++라이브러리로만 옮기는 것. 바로 그러한 오류의 전형이다.

그럼 조금만 바꿔보자. 위에서 strcat()으로 문자열을 합치는 부분은 C++표준의 string클래스를 사용하면 간편하게 될듯하다.

방법9)
char s[LENGTH];
string t;
do
{
    cin.clear()
    cin.getline( s, LENGTH );
    t += s;
} while( cin.fail() );


어떤가? 루프 내부는 한결 깔끔해졌다. 버퍼의 재할당과 문자열 복사라는 주요한 기능을 캡슐화한 string클래스를 사용함으로써 코드의 절반을 절약하는 성과를 이루어 냈다.
만 족스러운가? 아니다. 여전히 뭔가가 어색하다. 그 이유는: 바로 string '객체'와 문자'배열'이 혼재하고 있다는, 스타일의 불일치이다. 사람의 언어로 따지자면, 모 디자이너처럼 명사는 영어로, 조사만 우리말로 붙여서 쓰는 것과 같은 아주 어색한 말투에 비유할 수 있겠다.
그렇다면 입력버퍼로 사용하는 s가 문제이다. 위에서 말했듯이, s는 '문자배열'이지 '문자열'이 아닌 것이다. 그렇다면 s를 string객체로 대체할 수 있는 방법을 강구해야 한다.

아 마도, 여러분은 십중팔구 여기서 cin의 메소드 중에 string객체를 인자로 받는 멤버를 생각할 것이다. 그리고 도움말에서 검색을 시도하고는, 아마도, 자그마한 좌절을 경험하고는 '문자배열로만 입력받을 수밖에 없잖아!'라고 비명을 지르고는 말 것이다. 정말일까? 본인의 경험에 비추어 본다면, 검색 노력이 부족했다고 할수밖에 없겠다. cin은 istream클래스의 한 인스턴스이며, istream클래스로 검색해 본다면 조금 아래쪽에 istream_iterator라는 항목이 존재하는 것을 발견할 수 있을 것이다. istream_iterator에 대한 자세한 설명은 생략하겠지만, iterator패턴을 입력스트림에 대해 구현한 클래스템플릿이다.. 정도로만 일단 알아두자.
그렇다면 istream_iterator를 사용할 경우, 방법9)과 유사한 동작을 하는 코드는 다음과 같이 바뀐다.

방법10)
string s( istream_iterator( cin ), istream_iterator() );


어떤가? 경이적으로 코드가 줄어들었다. 위 코드는 지금까지 항상 속을 썩이던 문제, 즉, "사전에 예상되는 문자열의 길이 알기"라는 문제를 근본적으로 제거하였다.
꽤 나 만족스럽다. 그러나 문제가 있다. istream_iterator 템플릿은 입력을 받을때 operator>>을 사용하며, 결과적으로 cin >> XXX라는 동작을 반복하도록 되어 있는 이터레이터이다. 곧, 공백문자는 무시한다. 그래서 공백분자를 무시하지 못하도록 설정해보자.

방법11)
cin.unsetf( ios::skipws );
string s( istream_iterator( cin ), istream_iterator() );
cin.setf( ios::skipws );


원 하든 대로 동작하면서도 굉장히 깔끔한 코드를 손에 넣었다. 그러나, 문제가 있다. 바로 입력을 종료하기 위해서는 EOF캐릭터를 입력해야 한다는 것이다 (전통적으로는 ^Z를 입력함으로써 EOF캐릭터가 들어간다) 이는 istream_iterator는 기본적으로 파일스트림에 대해 사용하도록 되어 있는 클래스이기에 그러하다. 이걸 어떻게 해결해야 할까?

그렇다면 다시 cin.getline()을 보자. 역시나 우리가 원하는 동작은 getline이 가장 유사하다. 그렇다면 도움말 검색창에 getline이라고 쳐 보자. 어떠한가? basic_istream의 멤버인 getline()메서드와 함께 전역 getline() 템플릿함수도 나타날 것이다. getline()템플릿함수의 자세한 사용법은 생략하고, 이 함수를 사용하여 문자열을 입력받는 전형적인 예는 다음과 같다.

방법12)
string s;
getline( cin, s );


어떤 가? 원점으로 돌아온 느낌이 드는가? 아니다. 분명히 형태는 최초의 C버전의 코드와 굉장히 비슷하지만, 모든 것이 객체로 되어 있는 객체지향의 세계이며, 고질적인 "버퍼사이즈 미리알기"문제가 근본적으로 해결되었으며, 단 두줄밖에 안되는 깔끔한 코드이다.

'C++스러운' 코드를 작성하기 위해서는 많은 해결방법을 고려해볼 필요가 있다는 점을 말해두고 싶다.

Posted by uhm
dev.log2004. 1. 20. 01:13
오늘 모 사이트의 질문/답변란에서 다음과 같은 코드를 보았다.

ofstream out( filename );
vector< ClientData > clientVector;
clientVector.push_back( ... );
....
out.write(
reinterpret_cast< const char* >( &clientVector[0] ),
clientVector.size() * sizeof( clientVector[0] ) );

이 얼마나 C++스럽지 않은 코드인가!

왜 C++스럽지 않은지를 살펴보면, 우선 세가지 문제가 눈에 걸린다.

1) C++의 타입시스템을 무시하고 있다.
C++에서 특히 강화된 것 중의 하나가 타입시스템이다. 따라서 C++을 C++스럽게 쓰려면 타입시스템의 범위 내에서 동작을 만들어주는 것이 보다 양질의 코드를 생산하기 위한 가이드가 된다. 그러나 위의 코드는 vector가 담고 있는 원소의 주소를 강제로 캐스팅하여 파일에 저장하는 작업을 하고 있다.

2) 범용성이 부족하다.
STL 은 전적으로 이터레이터를 기반으로 구축된 라이브러리이다. 모든 컨테이너와 알고리듬은 어떤 형태로든 이터레이터를 기반으로 동작하도록 인터페이스가 구성되어 있다. (물론, 이터레이터를 제공하지 않는 stack, queue등의 컨테이너는 제외) 따라서 C++다운 코드를 구성하고자 한다면 STL컨테이너와 입출력스트림에 대한 작업은 이터레이터를 통해 하는 것이 정석이다. 컨테이너가 벡터가 아닌 다른 컷으로 바뀌더라도 동일한 코드로 동작할 수 있도록 하기 위함이다. 그러나 위의 코드는 이터레이터가 아닌, 메모리 주소를 가지고 작업하는 C스타일의 코드를 고수하고 있다. 당연히 벡터 이외의 다른 컨테이너가 필요할 경우에는 동작하지 않는다.


그렇다면 어떻게 고쳐야 'C++스러운' 코드가 될까?
여 러가지 방법이 있을 수 있겠지만, 한가지 방법은 ostream_iterator를 사용하는 것이다. ostream_iterator를 사용하기 위해서는 << 연산자를 (insertion operator라고 부른다) 오버로딩한 후 파일스트림을 가지고 원하는 이터레이터를 생성하면 된다. 다음 코드를 보자.

ofstream& operator<< ( ostream& os, ClientData& data )
{
os.wirte(
reinterpret_cast< const char* >&data,
sizeof( data ) );
return os;
}


이제 출력하기 위해서 ostream_iterator를 생성하고 copy알고리즘을 생성한 이터레이터에 대해 적용하면 된다.
copy( clientVector.begin(), 
clientVector.end(),
ostream_iterator< ClientData >( out ) );


물론 여기서도 캐스팅이 쓰이긴 했지만 이는 vector객체에 대해 쓰여진 것을 좀더 하위 레벨의 ClientData 타입으로 낮췄다는 면에서는 훨씬 더 나은 코드가 된다.
이 코드가 더 나아진 점은, clientVector 객체가 굳이 vector 컨테이너일 필요가 없다는 것이다. 컨테이너의 종류에 신경쓰지 말고 원하는 대로 담은 다음에 그저 copy 알고리듬을 호출하기만 하면 모든 것은 컴파일러가 알아서 생성해 준다는 것이다.

Posted by uhm