dev.log2004. 1. 30. 02:44
스테이트 머신은 복잡한 계의 행동을 모델링하는 간단한 기법으로 널리 애용되곤 한다. 스테이트 머신에 대한 자세한 설명은 여기서는 하지 않겠지만, 상태에 따라 외부의 자극에 다르게 반응하는 계를 모델링하는 기법이라고 설명하면 될듯 하다. 스테이트 머신에는 여러 종류가 있지만, 여기서는 가장 간단한 Deterministic Finite State Automata만을 고려하기로 한다.
오늘 누군가가 스테이트 머신 클래스를 만든 코드라고 한 것을 본 적이 있다. 대략적으로 다음과 같은 형상이었다.
void state_machine::handler()
{
    int state = INITIAL_STATE;
    while ( !is_final( state ) )
    {
        int s = fetch_stimuli();
        switch ( state )
        {
        case STATE0:
            state = on_state0( s );
            break;
        case STATE1:
            state = on_state1( s );
            break;
        // 다른 스테이트를 처리하는 케이스
        // ....
        default:
            state = INVALID_STATE;
        }
    }
}
int state_machine::on_state0( int s )
{
    switch ( s )
    {
    case STIMULI0:
        // 스테이트0에서 입력0를 처리하는 코드
        // ....
        return SOME_STATE;
    case STIMULI1:
        // 스테이트1에서 입력1을 처리하는 코드
        // ....
        return ANOTHER_STATE:
    // 다른 입력을 처리하는 케이스
    // ....
    default:
        return STATE0;
    }
}


이건 클래스만 썼을 뿐이지 C코드 그대로의 설계방식이다. 전혀 객체지향적이지 않다. 문제점이 보이는가?
어떤 스테이트 머신이 있을때, 그 머신의 스테이트0와 스테이트1에서의 행동은 많든 적든 다른 점이 있기 마련이다. 객체지향의 세계에서는 다른 일을 하는 개체는 다른 클래스로 만들어야 한다. 구현하고자 하는 기능을 그냥 <클래스에 때려박는다>고 해서 객체지향이 되는 것이 아니라는 것이다.

혹시 디자인 패턴 쪽을 공부해 보지 않은 분이 있다면 객체지향 설계 방법론에서 디자인 패턴은 한번 봐둘만한 가치는 있음을 말하고 싶다. 여기에서 나타나는 것이 state pattern, 혹은 strategy pattern이다. 스테이트 패턴은 어떤 객체의 스테이트를 하나의 객체로 모델링하는 기법이다.
위 스테이트 머신을 스테이트 패턴을 써서 고친다면 다음과 같이 변할 것이다.

우선, 객체의 스테이트를 나타내는 state 추상 클래스를 선언한다.

class state
{
public:
    virtual state* handle_stimuli( int s ) = 0;
};


이제 각 스테이트를 표현하는 클래스를 선언한다.
class state0 : public state
{
protected:
    state* handle_stimuli( int s )
    {
        switch ( s )
        {
        case STIMULI0:
            return handle_stimuli0();
        case STIMULI1:
            return handle_stimuli1();
        // 다른 입력을 처리하는 케이스
        // ....
        default:
            return new state0();
        }
    }
};


이제 메인 루프에서는 선언된 추상클래스의 인터페이스만 호출하면 된다.

void state_machine::handler()
{
    state* cur_state = new initial_state();
    while ( !is_final( state ) )
    {
        int s = fetch_stimuli();
        state* new_state = cur_state->handle_stimuli( s );
        delete cur_state;
        cur_state = new_state;
    }
    delete cur_state;
}


혹 동적 메모리의 할당과 해제가 빈번히 일어나는 것이 신경쓰인다면, 각 상태 객체를 미리 스테이트 머신 객체에 등록시켜 놓고 빠르게 접근하는 방법을 생각해도 되겠다. 이런 경우에는 Prototype패턴을 응용해 보면 좋을 것이다. 혹은, placement new 연산자를 오버로딩하여 사용하는 방법을 써도 좋다.

구현상의 개선이야 그렇다 치고, 이제는 설계상의 개선을 한번 찾아보자. 위 설계에 개선의 여지가 없는가? 당연히 있다. 문제는 스티뮬리를 처리하는 handle_stimuli()메소드의 구현 방식에 있다. 여기서는 스티뮬리의 값이 따라서 객체의 행동을 선택해주는 방식을 취하고 있다. 그렇다면 행동을 취하는 정보는 state객체에 있는 것이 아니라 스티뮬리에 있는 것이 된다. 그렇다면 행동의 선택은 이미 스티뮬리에 따라 주어진 것인데, 구태여 state객체가 스티뮬리에 따르는 행동의 선택에 관여할 필요는 없다는 결론이 나온다.
즉, 스테이트 객체가 스티뮬리를 처리하는 방식은 스티뮬리에 따라 결정되므로, 스티뮬리가 자기 자신을 처리하는 방식을 알고 있는 것이 합리적인 설계이다. 즉, 스티뮬리를 객체로 모델링하는 것이 정답이다. 여기에는 Command패턴을 쓰는 것이 적절하다.

우선 스티뮬리를 나타낼 추상클래스 stimuli를 선언한다.
class stimuli
{
public:
    virtual state* handle( state* s ) = 0;
};


이제 각 스티뮬리를 표현하는 클래스를 작성한다.
class stimuli0 : public stimuli
{
protected:
    state* handle( state* s )
    {
        return s->handle_stimuli0();
    }
};


그럼 이제 state객체의 선언이 바뀌게 된다.

class state0 : public state
{
protected:
    state* handle_stimuli( stimuli& s )
    {
        return s.handle( this );
    }
};


그러면 메인루프는 다음과 같아진다.

void state_machine::handler()
{
    state* cur_state = new initial_state();
    while ( !is_final( state ) )
    {
        stimuli& s = fetch_stimuli();
        state* new_state = cur_state->handle_stimuli(s);
        delete cur_state;
        cur_state = new_state;
    }
    delete cur_state;
}

처음의 코드와 가장 나중의 코드를 비교해 보면, 어느 쪽이 더 깔끔한지 판단해 보시기 바란다.

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