이 글은 제가 예전에 제가 활동하고 있는 학교 동아리에 "문자열을 입력받는 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)과 유사한 동작을 하는 코드는 다음과 같이 바뀐다.
어떤가? 경이적으로 코드가 줄어들었다. 위 코드는 지금까지 항상 속을 썩이던 문제, 즉, "사전에 예상되는 문자열의 길이 알기"라는 문제를 근본적으로 제거하였다. 꽤나 만족스럽다. 그러나 문제가 있다. istream_iterator 템플릿은 입력을 받을때 operator>>을 사용하며, 결과적으로 cin >> XXX라는 동작을 반복하도록 되어 있는 이터레이터이다. 곧, 공백문자는 무시한다. 그래서 공백분자를 무시하지 못하도록 설정해보자.
원하든 대로 동작하면서도 굉장히 깔끔한 코드를 손에 넣었다. 그러나, 문제가 있다. 바로 입력을 종료하기 위해서는 EOF캐릭터를 입력해야 한다는 것이다 (전통적으로는 ^Z를 입력함으로써 EOF캐릭터가 들어간다) 이는 istream_iterator는 기본적으로 파일스트림에 대해 사용하도록 되어 있는 클래스이기에 그러하다. 이걸 어떻게 해결해야 할까?
그렇다면 다시 cin.getline()을 보자. 역시나 우리가 원하는 동작은 getline이 가장 유사하다. 그렇다면 도움말 검색창에 getline이라고 쳐 보자. 어떠한가? basic_istream의 멤버인 getline()메서드와 함께 전역 getline() 템플릿함수도 나타날 것이다. getline()템플릿함수의 자세한 사용법은 생략하고, 이 함수를 사용하여 문자열을 입력받는 전형적인 예는 다음과 같다.
방법12)
string s; getline( cin, s );
어떤가? 원점으로 돌아온 느낌이 드는가? 아니다. 분명히 형태는 최초의 C버전의 코드와 굉장히 비슷하지만, 모든 것이 객체로 되어 있는 객체지향의 세계이며, 고질적인 "버퍼사이즈 미리알기"문제가 근본적으로 해결되었으며, 단 두줄밖에 안되는 깔끔한 코드이다.
'C++스러운' 코드를 작성하기 위해서는 많은 해결방법을 고려해볼 필요가 있다는 점을 말해두고 싶다.