대메뉴 바로가기 본문 바로가기

데이터 기술 자료

데이터 기술 자료 상세보기
제목 C++ 프로그래밍 : 가변 인자 함수와 문자열 클래스
등록일 조회수 7520
첨부파일  

C++ 프로그래밍

가변 인자 함수와 문자열 클래스



가변 인자 함수와 문자열 클래스의 관계를 살펴보면서 가변 인자 함수의 상세한 특징을 살펴보자.



1. 포맷 함수의 문자열 처리

지난 글에서 가변 인자 함수의 구조와 원리에 대해 살펴봤다. 가변 인자 함수는 주로 printf 같은 포맷류 함수에 주로 사용한다. 포맷류 함수의 목적은 숫자나 문자열을 적절히 조합해 일정한 형식의 문자열을 만들어내는 것이다. 당연히 포맷류 함수의 주요 인자로 문자열이 전달되는 경우가 많다. 보통 문자열을 나타내는 포맷 기호로 %s가 사용되는데, 포맷에 %s가 존재할 경우 이에 대응해 가변 인자로 적절한 문자열을 넘겨줘야만 한다. 제대로 문자열을 넘겨주지 않을 경우 런타임 에러가 발생할 수 있다. 이번 글에는 가변 인자 함수와 문자열 클래스의 관계를 살피면서 가변 인자 함수의 상세한 특징을 좀 더 알아볼 것이다.



<리스트 1> printf - %s void main() { printf(“%s - %d년”, “마소 창간”, 1983); // ① }



<리스트 1>의 출력 결과는 다음과 같다.

마소 창간 - 1983년

보통 ①과 같이 %s에 대응되는 인자로 문자열을 전달한다. 좀 더 정확히 말하면 NULL로 끝나는 문자열을 가리키는 주소를 전달한다. 타입으로 따진다면 (const) char*라고 할 수 있다. 그러나 문자열을 인자로 전달할 때 항상 (const) char* 타입만 전달해야 하는 것은 아니다. 윈도우 C++ 프로그래밍에서 주로 사용하는 문자열 클래스 CAtlString의 경우 객체를 그대로 전달해도 전혀 문제가 없다. 주의할 점이 있는데, CAtlString이 %s와 호환된다고 해서 다른 문자열 클래스까지 자유롭게 호환되는 것은 아니라는 사실이다. C++ 표준 라이브러리에서 제공하는 대표적 문자열 클래스인 std::string의 객체를 printf 인자로 넘길 경우 컴파일러 및 환경에 따라 문제가 발생할 수 있다.



2. CAtlString & std::string

<리스트 2> CAtlString & std::string void main() { CAtlString atlstr = “Hello”; std::string stdstr = “World”; printf(“%s”, atlstr); // ① OK printf(“%s”, stdstr); // ② Runtime Error }



<리스트 2>의 코드를 윈도우 환경의 VC++로 컴파일하고 실행해보자. ②부분에서 런타임 에러가 발생한다. CAtlString 객체인 atlstr은 무사히 전달되는 반면에 std::string 객체인 stdstr이 전달되면서 문제를 발생시킨다. 어느 정도 경험이 있는 개발자라면 printf에서 std::string 객체를 안전하게 전달하는 방법을 잘 알고 있을 것이다. 바로 stdstr 대신에 stdstr.c_str()을 사용하는 것이다. 그렇다면 CAtlString에 비해 왜 std::string은 추가적으로 멤버 함수 c_str을 사용해야만 하는 것일까? 답을 찾기 위해 지난 시간처럼 오직 포맷 인자 %s 단 하나만을 받아들여 출력하는 가변 인자 함수 VAFunc를 만들어보자.



<리스트 3> VAFunc void VAFunc(const char* format, ...) { char* arg_ptr; arg_ptr = (char*)&format; arg_ptr += sizeof(void*); char* str = *(char**)arg_ptr; // ③ arg_ptr = NULL; cout < < str < < endl; } void main() { CAtlString atlstr = “Hello”; std::string stdstr = “World”; VAFunc(“%s”, atlstr); // ① OK VAFunc(“%s”, stdstr); // ② Runtime Error }



<리스트 3>의 주요 변화라고 한다면 printf를 대신하는 가변 인자 함수 VAFunc가 도입됐으며 ①, ②에서 printf 대신 VAFunc를 호출한다는 점이다. 가장 중요한 부분은 ③이다. 포맷 인자로 %s가 주어질 경우 printf를 비롯한 포맷류 함수 내부에서 ③과 같은 코드가 사용된다(만일 ③과 같은 코드가 잘 이해되지 않는다면 지난 글 가변 인자 함수 부분을 다시 한 번 정독하기를 추천한다). ③이 의미하는 것은 다음과 같다. arg_ptr이 가리키는 스택의 영역에는 NULL 종료 문자열을 가리키는 주소가 들어있다고 생각한다. 따라서 해당 문자열을 얻기 위하여 arg_ptr을 char**로 포인터 타입 변환 후에 값 연산자(*)를 통해서 char* str이 문자열을 가리키도록 한다. 즉, *(char**)arg_ptr은 arg_ptr이 가리키는 곳에 들어있는 문자열의 주소를 char*로 변환한다.

여전히 ①과 같이 CAtlString 객체는 별 문제가 없지만 ②와 같이 std::string 객체는 문제를 발생시킨다. 바로 std::string의 클래스 구조가 ③의 변환 과정에서 문제를 일으키기 때문이다. 그림을 통해서 CAtlString과 std::string의 구조를 살펴보자.



<그림 1>은 CAtlString과 std::string의 구조를 보여준다. std::string의 경우 컴파일러 제작사마다 그리고 컴파일러 버전에 따라서 구조가 달라질 수 있는데, 그림은 x86 시스템의 VC++ 컴파일러를 기준으로 했다. 그림에서 중요한 점은 CAtlString의 경우 멤버가 오직 단 하나 존재하며 멤버 m_pszData의 타입이 char*라는 것이다. 따라서 CAtlString의 크기는 sizeof(char*)이 되므로 4바이트가 된다.

바로 이런 점으로 인해 CAtlString은 객체를 printf와 같은 포맷류 함수의 인자로 전달해도 아무 문제가 없다. <그림 1>은 CAtlString과 std::string 객체가 인자로 전달돼 스택에 배치된 상태를 보여주는데, CAtlString 구조에서 arg_ptr이 가리키는 굵은 박스인 스택 영역에는 실제 문자열을 가리키는 주소가 있으므로 *(char**)arg_ptr이 바로 m_pszData가 된다.

그에 비해 std::string은 구조가 전혀 다르다. 일단 28바이트를 차지할 정도로 크기가 크며, 실제 문자열을 가리키는 주소인 _Ptr이 arg_ptr이 가리키는 굵은 박스에 존재하지 않는다. 따라서 *(char*)arg_ptr은 문자열이 아닌 전혀 엉뚱한 곳을 가리키게 되며 문자열을 출력하기 위해 메모리에 접근할 경우 런타임 에러가 발생한다.

std::string을 printf에 전달할 경우에는 멤버 함수인 c_str을 사용해야 한다. c_str은 바로 _Ptr이 나타내는 실제 문자열의 주소를 반환하기 때문이다. 이런 문제로 인해서 마이크로소프트는 CAtlString을 설계할 때부터 그림과 같은 구조를 반영한 것이다. 그만큼 세심한 배려의 흔적이 느껴진다.

VC++로 개발을 할 경우 MFC의 CString과 ATL의 CAtlString을 사용하면 큰 문제가 없지만, 리눅스 환경의 GCC로 개발할 경우 문자열 클래스로 std::string을 쓰지 않을 수 없다. 즉, GCC로 개발한 프로그램에서 ②와 같은 런타임 에러가 발생하는 경우가 종종 있었다. 이런 문제를 해결하기 위해 GCC도 VC++과 같은 방향으로 발전하고 있다. 최신 버전인 GCC 4.9.0에서는 std::string의 크기가 4바이트며 문자열 데이터를 가리키는 포인터만 멤버로 가지고 있다. 즉, VC++의 CAtlString과 비슷한 구조로 가기 시작한 것이다. 그렇다면 GCC에서는 printf에 std::string 객체를 c_str()을 사용하지 않고 그대로 인자로 넘겨도 상관이 없을까? 꼭 그런 것은 아니다. 예전 버전 GCC에서는 string 구조가 지금과 같지 않아서 런타임 에러가 발생했지만, 최신 버전인 GCC 4.9.0에서는 컴파일 에러가 발생한다(이전 버전에서는 런타임 에러가 발생하던 코드가 최신 버전에서 멀쩡히 돌아간다면 일관성 측면에서 어긋나기 때문인지 런타임 에러가 나는 곳을 컴파일 에러가 발생하도록 변경했다). 즉, c_str()을 사용하지 않으면 컴파일조차 되지 않도록 안정성을 강화한 것이다. 참고로 컴파일 에러는 다음과 같다.

cannot pass objects of non-trivially-copyable type!

대략적인 의미는 메모리 복사로 객체를 복사할 수 없는 타입일 경우 가변 인자로 넘기는 것을 허용하지 않겠다는 것이다. 에러 내용만 봐서는 감이 오지 않을 수 있는데, 정확한 내용을 파악하기 위해 trivially-copyable type에 대해서 살펴보겠다.



3. trivially-copyable type

<리스트 4> 복사 생성자와 가변 인자 함수 class CTest // ⓐ { public: CTest() {} CTest(const CTest& arg) { m_str = “Copy Constructor!”; } char* m_str; }; void VAFunc(const char* format, ...) // ① { char* arg_ptr; arg_ptr = (char*)&format; arg_ptr += sizeof(void*); char* str = *(char**)arg_ptr; arg_ptr = NULL; cout < < str < < endl; } void Func(const char* format, CTest arg) // ② { char* arg_ptr; arg_ptr = (char*)&format; arg_ptr += sizeof(void*); char* str = *(char**)arg_ptr; arg_ptr = NULL; cout < < str < < endl; } void main() { CTest t; t.m_str = “Hello World!”; // ③ VAFunc(“%s”, t); // ④ Func(“%s”, t); // ⑤ }



<리스트 4>를 통해 trivially-copyable type에 대해 알아보자. 먼저 ⓐ를 살펴보자. CAtlString이나 GCC 최신 버전의 std::string과 구조가 비슷한 문자열 클래스 CTest를 만들어봤다. CTest에는 실제 문자열을 가리키는 멤버 m_str만을 가지고 있다. 추가적으로 CTest에서 눈여겨볼 점은 복사 생성자가 정의돼 있으며, 복사 생성자가 호출될 경우 문자열을 가리키는 멤버 m_str을 “Copy Constructor!”으로 설정한다는 점이다. ①과 ②를 살펴보자. 가변 인자 함수 VAFunc와 일반 함수인 Func를 준비했다. 중요한 점이 있는데, 가변 인자 여부만 다를 뿐이지, 함수 본체는 완전히 일치하도록 만들었다. 일반 함수 Func는 두 번째 인자로 CTest 타입을 받을 뿐이다. 여기서 생각해야 할 것이 있다. 비록 VAFunc가 가변 인자 함수라고 하지만 main의 ④, ⑤와 같이 두 번째 인자로 CTest 객체 t를 전달한다면 VAFunc와 Func는 완전 동치 함수로써 같은 결과를 나타내게 될까? 어떤 결과가 나올지 한번 생각해보길 바란다. 궁금한 독자를 위해 실제로 결과를 확인해보자.

Hello World! // VAFunc 결과
Copy Constructor! // Func 결과

결과에서 확인할 수 있듯이, 함수 본체가 완전히 똑같다고 해도 가변 인자 여부에 따라서 결과가 완전히 달라지는 것을 확인할 수 있다. 이와 같은 차이가 발생하는 근본적인 원인이 있는데, 바로 C++의 인자 전달 특성 때문이다. C++에서는 값에 의한 호출로써 클래스(구조체 포함) 객체가 인자로 전달되는 경우 새로운 객체가 생성되면서 (암시적 혹은 명시적)복사 생성자가 호출된다. 즉, ⑤와 같이 Func에 CTest 객체 t를 실인자로 전달할 경우, ②의 매개변수 CTest arg가 생성되면서 복사 생성자를 호출하게 된다. 따라서 arg의 m_str은 “Copy Constructor!”를 나타내게 된다. 그렇다면 왜 가변 인자 함수인 VAFunc에서는 복사 생성자가 호출되지 않았을까? 바로 인자로 전달되는 가변 인자의 타입을 모르기 때문이다. 인자의 타입, 즉 클래스를 알 수 없기 때문에 어떤 클래스의 복사 생성자를 호출해야 될지도 알 수 없기 때문이다.

여기서 의문이 들 수도 있다. 분명히 실인자로 넘기는 t의 타입은 CTest가 명확하므로 CTest의 복사 생성자를 호출하면 될 것 같지만, 그것은 사람이 볼 때나 그렇지, 컴파일러 입장에서는 전혀 그렇지 않다. 가변 인자로는 어떤 타입도 올 수 있으므로, 컴파일러가 가변 인자 함수를 호출하는 코드를 구성할 때는 오직 실인자의 실제 크기만큼을 스택 영역에 마련해놓고 순수하게 메모리 복사만을 수행하기 때문이다. 결국 가변 인자 함수에 전달되는 가변 인자는 오직 메모리 복사만이 일어날 뿐이지 절대로 복사 생성자가 호출되지 않는다.

왜 가변 인자 함수와 일반 함수의 인자 전달 방식 차이를 설명한 것인지 궁금할 수도 있는데, 바로 trivially-copyable type을 설명하기 위해서다. trivially-copyable type이란 순수한 메모리 복사만으로 인자가 복사될 수 있는 타입을 의미한다. 보통 int, double, char*와 같은 원시 타입이나 복사 생성자가 전혀 필요없는 정말 간단한 구조체 정도가 바로 trivially-copyable type이라고 할 수 있다. 즉, 간단히 정리를 하면 최신 버전의 GCC에서는 가변 인자 함수에 전달할 수 있는 인자의 타입을 오직 trivially-copyable type으로만 제한하고 있는 것이다. 만일 trivially-copyable type이 아닌 (암시적 혹은 명시적)복사 생성자가 존재하는 클래스의 객체를 전달할 경우 개발자는 복사 생성자가 호출되기를 기대하지만, 실제로는 복사 생성자가 호출될 수 없음으로 인하여 문제가 발생할 소지가 있기 때문이다.

일단 이것만 기억하자! 가변 인자 함수에 전달하는 인자의 타입은 복사 생성자가 존재할 수 있는 클래스가 아니어야 한다는 사실이다. 사실 대부분의 클래스는 암시적이건 명시적이건 복사 생성자가 존재한다. 물론 오직 데이터만을 모아놓는 용도로 설계된 단순한 구조체의 경우 복사 생성자 자체가 존재하지 않을 수도 있다. 가령 점을 표현하는 구조체 Point를 설계할 때, 오직 멤버로 좌표를 표시하는 int 타입 x, y만 존재하고, 어떤 생성자도 정의하지 않는다면 구조체 Point는 trivially-copyable type이 된다. 당연한 얘기지만 std::string 클래trivially-copyable type이 될 수 없다. 따라서 최신 버전 GCC에서는 std::string 객체를 가변 인자 함수인 printf에 인자로 전달할 수 없는 것이다(c_str을 사용할 경우 전혀 문제가 없는데, c_str이 반환하는 타입은 바로 trivially-copyable type이라고 할 수 있는 const char*이기 때문이다).

간단하게나마 문자열 클래스 CAtlString과 std::string에 대해 비교를 해보자. 몇몇 개발자들은 필자에게 아주 가끔씩 CAtlString(MFC CString 포함)과 std::string중에 어떤 것이 더 좋은지 물어보는 경우가 있는데, 간단히 답변을 한다면 멀티 플랫폼에서 호환되는 코드를 작성한다면 C++ 표준 라이브러리가 제공하는 std::string을 사용하는 것이 당연하겠지만, 오직 윈도우 기반에서만 돌아가는 코드를 작성한다면 CAtlString을 사용하는 편이 훨씬 유리하다는 것을 말하고 싶다. CAtlString 자체가 VC++에서 사용하기에 가장 잘 설계된 문자열 클래스이기 때문에 std::string이 갖지 못한 편리한 기능들을 제공하기도 하며, 윈도우 개발에서는 CRT 라이브러리가 static(/MT) 혹은 dll(/MD)로 분리돼 제공될 수 있는데, 이와 같은 환경에서는 std::string을 사용할 경우 힙 처리 과정에서 치명적인 문제가 발생하는 경우도 있기 때문이다. 결론은 CAtlString이 윈도우 기반에서 개발하기에 좀 더 편안하고 안전하게 설계되었다는 것이다.



정리

가변 인자 함수와 더불어 대표적인 문자열 클래스 CAtlString과 std::string을 살펴보았다. printf와 같은 포맷 함수를 만들기 위해서는 가변 인자 함수가 반드시 필요하다. 그리고 가변 인자 함수의 인자 전달 및 처리에 대한 방식은 문자열 클래스를 설계하는데 많은 영향을 주었음을 알 수 있다. 동시에 문자열 클래스는 다시 컴파일러 설계에도 영향을 주는 것을 알 수 있다.

가장 중요한 특징이라고 한다면 가변 인자 함수는 일반 함수와 비교해 값에 의한 호출로 인자가 전달될 때 절대로 복사 생성자가 호출되지 않는다는 것이다. 이것은 다른 말로 한다면 다음과 같다. 보통의 함수에서 값에 의한 호출로 인자가 전달될 때는 복사 생성자가 호출된다. 너무 당연한 것 같지만, C++에서는 무척 중요한 부분이라고 할 수 있다.

마지막으로 지면의 한계로 인해 자세한 설명과 예제를 보여주지 못하는 경우도 있는데, C++의 본질적인 부분을 알고자 하는 독자는 필자의 저서 [Fundamental C++ 프로그래밍 원리]를 참고하시길 부탁드린다.



출처 : 마이크로소프트웨어 2월호

제공 : 데이터전문가 지식포털 DBguide.net