전문가를 위한 C++/ 스트링과 스트링 뷰 다루기

(전체가 아니라 C#과 차이가 있는 부분을 중심으로 요약 정리)

동적 스트링

스트링을 주요 객체로 제공하는 프로그래밍 언어를 보면 스트링 크기를 임의로 확장하거나 서브스트링을 추출하거나 교체하는 것처럼 고급기능을 제공하지만 C와 같은 언어는 스트링을 부가 기능처럼 취급한다. 그래서 스트링을 언어의 정식 데이터 타입으로 제공하지 않고 단순히 고정된 크기의 바이트 배열로 처리했다.

C 스타일 스트링

C 언어는 스트링을 문자 배열로 표현했다. 스트링의 마지막에 널 문자(\0)를 붙여서 스트링이 끝났음을 표현했다. 이러한 널 문자에 대한 공식 기호는 NUL 이다. 여기서 L이 두 개가 아니며 NULL 포인터와는 다른 값이다.

(이하 C의 스트링 내용 생략. 스트링을 배열로 다루는데다가 마지막에 \0을 추가하기 위해 배열의 길이를 +1 더 써야 한다는데, C를 쓰는 사람이 아니라 요즘 프로그래밍 환경을 쓰는 사람이라면 굳이 살펴보지 않아도 될 듯)

Caution) 마이크로소프트 비주얼 스튜디오에서 C 스타일 스트링 함수를 사용하면 컴파일러에서 보안 관련 경고나 이 함수가 폐기됐다는 에러 메시지가 출력될 수 있다.

스트링 리터럴

cout << "hello" << endl;

위 예시와 같이 ‘hello’ 처럼 변수에 담지 않고 곧바로 값을 표현한 스트링을 스트링 리터럴이라 부른다. 스트링 리터럴은 내부적으로 읽기 전용 영역에 저장된다. 그래서 컴파일러는 스트링 리터럴이 코드에 여러 번 나오면 그중 한 스트링에 대한 레퍼런스를 재사용하는 방식으로 메모리를 절약한다.

다시 말해 코드에서 ‘hello’란 스트링 리터럴을 500번 넘게 사용해도 컴파일러는 hello에 대한 메모리 공간을 딱 하나만 할당하는데, 이를 리터럴 풀링(literal pooling)이라 한다.

스트링 리터럴을 변수에 대입할 수 있지만 메모리의 읽기 전용 영역에 있게 되거나 동일한 리터럴을 여러 곳에서 공유할 수 있기 때문에 변수에 저장하면 위험하다.

C++ 표준에서는 스트링 리터럴을 ‘const char가 n개인 배열’ 타입으로 정의하고 있다. 하지만 const가 없던 시절에 작성된 레거시 코드의 하위 호환성을 보장하도록 스트링 리터럴을 const char*가 아닌 타입으로 저장하는 컴파일러도 많다.

const 없이 char* 타입 변수에 스트링 리터럴을 대입하더라도 그 값을 변경하지 않는 한 프로그램 실행에는 문제가 없다. 스트링 리터럴을 수정하는 동작에 대해서는 명확히 정의돼 있지 않다. 따라서 프로그램이 갑자기 죽을 수도 있고, 실행은 되지만 겉으로 드러나지 않는 효과가 발생할 수도 있고, 수정 작업을 그냥 무시할 수도 있다. 예컨대 다음과 같이 코드를 작성하면 결과를 예측할 수 없다.

char* ptr = "hello";  // 변수에 스트링 리터럴을 대입한다.
ptr[1] = 'a'; // 결과를 예측할 수 없다.

스트링 리터럴을 참조할 때는 const 문자에 대한 포인터를 사용하는 것이 훨씬 안전하다. 다음 코드도 위와 똑같은 버그를 담고 있지만, 스트링 리터럴을 const char* 타입 변수에 대입했기 때문에 컴파일러가 걸러낼 수 있다.

const char* ptr = "hello";  // 변수에 스트링 리터럴을 대입한다.
ptr[1] = 'a'; // 읽기 전용 메모리에 값을 쓰기 때문에 에러가 발생한다.

문자 배열(char[])의 초깃값을 설정할 때도 스트링 리터럴을 사용한다. 이때 컴파일러는 주어진 스트링을 충분히 담을 정도로 큰 배열을 생성한 뒤 여기에 실제 스트링값을 복사한다. 컴파일러는 이렇게 만든 스트링 리터럴을 읽기 전용 메모리에 넣지 않으며 재사용하지도 않는다.

char arr[] = "hello";  // 컴파일러는 적절한 크기의 문자 배열 arr을 생성한다.
arr[1] = 'a'; // 이제 스트링을 수정할 수 있다.

로 스트링 리터럴

로 스트링 리터럴(raw string literal)이란 여러 줄에 걸쳐 작성한 스트링 리터럴로서 그 안에 담긴 인용 부호를 이스케이프 시퀀스로 표현할 필요가 없고, \t나 \n 같은 이스케이프 시퀀스를 일반 텍스트로 취급한다.

예컨대 일반 스트링 리터럴을 다음과 같이 작성하면 컴파일 에러가 발생한다.

const char* str = "Hello "World"!";  // 에러 발생

이때는 큰 따옴표를 다음과 같이 이스케이프 시퀀스로 표현한다.

const char* str = "Hello \"World\"!";

하지만 로 스트링 리터럴을 사용하면 인용부호를 이스케이프 시퀀스로 표현하지 않아도 된다. 로 스트링 리터럴을 R”(로 시작해서 )”로 끝난다.

const char* str = R"("Hello "World"!)";

로 스트링 리터럴을 사용하지 않고 여러 줄에 걸친 스트링을 표현하려면 줄이 바뀌는 지점에 \n을 넣어야 한다.

const char* str = "Line 1\nLine 2";

로 스트링 리터럴로 표현할 때는 그냥 엔터키를 누르면 된다. (이거는 오히려 더 불편한 듯)

const char* str = R"(Line 1
Line 2)";

로 스트링 리터럴에서는 이스케이프 시퀀스를 무시하기 때문에 다음과 같이 로 스트링 리터럴을 작성하면 \t 이스케이프 시퀀스가 탭 문자로 바뀌지 않고 백슬래시 뒤에 t라는 문자가 나온 것으로 표현된다.

const char* str = R"(Is the following a tab character? \t)";

로 스트링 리터럴은 )”로 끝나기 때문에 그 안에 )”를 넣을 수 없다. 예컨대 다음과 같이 중간에 )”가 들어가면 에러가 발생한다.

const char* str = R"(Embedded )" characters)";  // 에러 발생

)” 문자를 추가하려면 다음과 같이 확장 로 스트링 리터럴(extended raw string literal) 구문으로 표현해야 한다.

R"d-char-sequence(r-char-sequence)d-char-sequence"

여기서 r-char-sequence에 해당한느 부분이 실제 로 스트링이다. d-char-sequence라고 표현한 부분은 구분자 시퀀스로서 반드시 로 스트링 리터럴의 시작과 끝에 똑같이 나와야 한다. 이 구분자 시퀀스는 최대 16개의 문자를 가질 수 있다. 이때 구분자 시퀀스는 로 스트링 리터럴 안에 나오지 않는 값으로 지정해야 한다.

const char* str = R"-(Embedded )" characters)-"; 

C++ std::string 클래스

C++ 표준 라이브러리는 스트링을 좀 더 잘 표현하도록 std::string 클래스를 제공한다. 엄밀히 말해 std::string은 basic_string이라는 클래스 템플릿의 인스턴스로서 <cstring>의 함수와 기능은 비슷하지만 메모리 할당 작업을 처리해주는 기능이 더 들어 있다.

C 스타일 스트링의 문제점

장점

  • 간단하다. 내부적으로 기본 문자 타입과 배열 구조체로 처리한다.
  • 가볍다. 제대로 사용하면 메모리를 꼭 필요한 만큼만 사용한다.
  • 로우 레벨이다. 따라서 메모리의 실제 상태를 조작하거나 복사하기 쉽다.
  • C 프로그래머에게 익숙하다.

단점

  • 스트링 데이터 타입에 대한 고차원 기능을 구현하려면 상당한 노력이 필요하다.
  • 찾기 힘든 메모리 버그가 발생하기 쉽다.
  • C++의 객체지향적인 특성을 제대로 활용하지 못한다.
  • 프로그래머가 내부 표현 방식을 이해해야 한다.

C++의 string은 C 스타일의 스트링이 가진 장점은 유지하면서 단점을 해결해 준다.

string 클래스 사용법

string은 실제로는 클래스지만 마치 기본 타입인 것처럼 사용한다. 그래서 코드를 작성할 때는 기본 타입처럼 취급하면 된다. C++ string에 연산자 오버로딩을 적용하면 C 스타일 스트링보다 훨씬 사용하기 편하다.

(+, += 연산 예시 생략)

C 스타일 스트링은 == 연산자로 비교할 수 없다는 단점이 있다. C 언어에서 스트링을 비교하려면 다음과 같이 작성한다.

if (strcmp(a, b) == 0)

또한 C 스트링은 <, <=, >=, >로 비교할 수 없기 때문에 주어진 스트링을 사전식 나열 순서에 따라 비교해서 -1, 0, 1을 리턴하는 strcmp()를 사용했다. 따라서 코드가 지저분하고 읽기 힘들 뿐 아니라 에러가 발생하기 쉽다.

C++에서 제공하는 string에서는 ==, !=, < 와 같은 연산자를 오버로딩해서 스트링에 적용할 수 있다. 물론 C 처럼 각각의 문자를 []로 접근할 수도 있다.

다음 코드를 보면 연산자 오버로딩으로 string을 확장해도 메모리 관련 작업은 string 클래스가 알아서 처리해준다는 것을 알 수 있다. 따라서 (허용된 범위를 벗어나는) 메모리 오버런(memory overrun)이 발생할 걱정을 할 필요 없다.

string myString = "hello";
myString += ", there";
string myOtherString = myString;

if (mystring == myOtherString)
{
myOtherString[0] = 'H';
}

cout << myString << endl;
cout << myOtherString << endl;

위 코드의 결과는 다음과 같다.

hello, there
Hello, there

위 예제에서 몇 가지 짚고 넘어갈 점이 있다. 첫째, 스트링을 할당하거나 크기를 조절하는 코드가 여러 군데 흩어져 있어도 메모리 누수가 발생하지 않는다. string 객체는 모두 스택 변수로 생성되기 때문이다. string 클래스를 사용하면 메모리를 할당하거나 크기를 조절할 일이 상당히 많긴 하지만 string 객체가 스코프를 벗어나자마자 여기에 할당된 메모리를 string 소멸자가 모두 정리한다.

둘째, 연산자를 원하는 방식으로 작동하게 할 수 있다. 예컨대 = 연산자를 스트링을 복사하는데 사용하면 상당히 편하다. 배열 방식으로 스트링을 다루는데 익숙하다면 이 기능이 굉장히 편하긴 하지만 좀 헷갈릴 수 있다. 그래도 걱정할 필요 없다. 일단 string 클래스의 처리 방식이 좋다는 것을 깨닫는 순간 금세 적응하게 된다.

string 클래스에서 제공하는 c_str() 메서드를 사용하면 C 언어에 대한 호환성을 보장할 수 있다. 이 메서드는 C 스타일 스트링을 표현하는 const 문자 포인터를 리턴한다. 하지만 string에 대한 메모리를 다시 할당하거나 해당 string 객체를 제거하면 이 메서드가 리턴한 const 포인터를 더 사용할 수 없게 된다. 따라서 현재 string에 담긴 내용을 정확시 하용하려면 이 메서드를 호출한 직후에 리턴된 포인터를 활용하도록 코드를 작성하는 것이 좋다.

또한 함수 안에 생성된 스택 기반 string 객체에 대해서는 c_str() 을 호출한 결과를 절대로 리턴 값으로 전달하면 안 된다.

또한 string에서 제공하는 data() 메서드는 C++ 14까지만 해도 c_str() 처럼 const char* 타입으로 값을 리턴했다. 그러나 C++ 17부터는 non-const 스트링에 대해 호출하면 char*를 리턴하도록 변경됐다.

std::string 리터럴

소스 코드에 나온 스트링 리터럴은 주로 const char*로 처리한다. 표준 사용자 정의 리터럴 ‘s’를 사용하면 스트링 리터럴을 std::string으로 만들 수 있다.

auto string1 = "Hello World";  // strin1의 타입은 const char*이다.
auto string2 = "Hello World"; // string2의 타입은 std::string이다.

하이레벨 숫자 변환

숫자를 string으로 변환하는 함수는 to_string이다.

(예시 생략)

string을 숫자로 변환하는 함수는 다음과 같아. 이 함수의 프로토타입에서 str은 변환하려는 원본 string 값을 의미하고, idx는 아직 변환되지 않은 부분의 맨 앞에 있는 문자의 인덱스를 가리키는 포인터고, base는 변환할 수의 밑(기수, 기저)이다. idx 포인터를 널 포인터로 지정하면 이 값을 무시한다.

여기 나온 변환 함수들은 제일 앞에 나온 공백 문자를 무시하고, 변환에 실패하면 invalid_argument 익셉션을 던지고 변환된 값이 리턴 타입의 범위를 벗어나면 out_of_range 익셉션을 던진다.

int stoi (const string& str, size_t *idx=0, int base=10);
long stol (const string& str, size_t *idx=0, int base=10);
unsigned long stoul (const string& str, size_t *idx=0, int base=10);
long long stoll (const string& str, size_t *idx=0, int base=10);
unsigned long long stoull (const string& str, size_t *idx=0, int base=10);
float stof (const string& str, size_t *idx=0);
double stod (const string& str, size_t *idx=0);
long double stold (const string& str, size_t *idx=0);

예컨대 다음과 같다.

const string toParse = " 123USD";
size_t index = 0;
int value = stoi(toParse, &index);
cout << "Parsed value: " << value << endl;
cout << "First non-parsed character: '" << toParse[index] << "'" << endl;

실행 결과는 다음과 같다.

Parsed value: 123
First non-parsed character: 'U'

로우 레벨 숫자 변환

C++ 17부터 로우 레벨 숫자 변환에 대한 함수도 다양하게 제공된다. 이 함수는 <charconv> 헤더에 정의돼 있다. 이 함수는 메모리 할당에 관련된 작업은 전혀 해주지 않기 때문에 호출한 측에서 버퍼를 할당하는 방식으로 사용해야 한다.

또한 고성능과 로케일 독립성(locale-independent)에 튜닝됐다. 그래서 다른 하이 레벨 숫자 변환 함수에 비해 처리 속도가 엄청나게 빠르다. 숫자 데이터와 사람이 읽기 좋은 포맷(JSON, XML 등) 사이의 변환 작업을 로케일에 독립적이면서 빠른 속도로 처리하고 싶다면 이러한 로우 레벨 함수를 사용한다.

정수를 문자로 변환하려면 다음과 같은 함수를 사용한다.

to_chars_result to_chars(char* first, char* last, IntegerT value, int base= 10);

여기서 IntegerT 자리에 부호 있는 정수나 부호 없는 정수 또는 char 타입이 나올 수 있다. 결과는 to_chars_result 타입으로 리턴되며 다음과 같이 정의돼 있다.

struct to_chars_results
{
char* ptr;
errc ec;
}

정상적으로 변환됐다면 ptr 멤버는 끝에서 두 번째 문자를 가리키고, 그렇지 않으면 last 값과 같다. (이때 ec == errc::value_too_large다)

예컨대 다음과 같다.

std::string out(10, ' ');
auto result = std::to_chars(out.data(), out.data() + out.size(), 12345);
if (result.ec == std::errc()) { /* 제대로 변환된 경우 */ }

1장에서 소개한 C++ 17의 구조적 바인딩을 적용하면 다음과 같이 표현할 수 있다.

std::string out(10, ' ');
auto [ptr, ec] = std::to_chars(out.data(), out.data() + out.size(), 12345);
if (ec == std::errc()) { /* 제대로 변환된 경우 */ }

다음과 같이 부동소수점 타입에 대한 변환 함수도 제공한다.

to_chars_result to_chars(char* first, char* last, FloatT value);
to_chars_result to_chars(char* first, char* last, FloatT value, chars_format format);
to_chars_result to_chars(char* first, char* last, FloatT value, chars_format, int precision);

여기서 FloatT 자리에 float, double, long double이 나올 수 있다. 구체적인 포맷은 다음과 같이 정의된 chars_format 플래그를 조합해서 지정할 수 있다.

enum class chars_format
{
scientific, // 스타일: (-)d.ddde ±dd
fixed, // 스타일: (-)ddd.ddd
hex, // 스타일: (-)h.hhhp ±d (주의: 0x는 적지 않는다)
general = fixed | scientific // 다음 문단 참조
}

기본 포맷인 chars_format::general을 적용하면 to_chars()는 부동소수점값을 십진수 표기법인 (-)ddd.ddd와 십진수 지수 표기법인 (-)d.ddde±dd 중에서 소수점 왼쪽에 나오는 숫자를 표기할 때 전체 길이가 가장 짧은 형태로 변환된다. (예: 100.001 보다는 1.0e+6 선택).

포맷에 정밀도(precision)을 지정하지 않으면 주어진 포맷에서 가장 짧게 표현할 수 있는 형태로 결정된다. 참고로 정밀도의 최댓값은 여섯자리다.

반대 방향, 즉 스트링을 숫자로 변환하는 함수도 있다.

from_char_result from_chars(const char* first, const char* last, IntegerT& value, int base = 10);
from_char_result from_chars(const char* first, const char* last, FloatT& value, chars_format format = chars_format::general);

from_chars_result 타입은 아래와 같이 정의돼 있다.

struct from_char_results
{
const char* ptr;
errc ec;
}

여기서 ptr 멤버는 변환에 실패할 경우 첫 번째 문자에 대한 포인터가 되고, 제대로 변환될 때는 last와 같다. 변환된 문자가 하나도 없다면 ptr은 first와 같으며, 에러 코드는 errc::invalid_argument가 된다. 파싱된 값이 너무 커서 지정된 타입으로 표현할 수 없다면 에러 코드의 값은 errc:result_out_of_range가 된다. 참고로 from_char()는 앞에 나온 공백 문자를 무시하지 않는다.

std::string_view 클래스

C++17 이전에는 읽기 전용 스트링을 받는 함수의 매개변수 타입을 쉽게 결정할 수 없었다. const char*로 지정하면 std::string을 사용하는 클라이언트에서 c_str()나 data()를 이용하여 string을 const char*로 변환해서 호출해야 한다.

더 심각한 문제는 이렇게 하면 std:;string의 객체지향 속성과 여기서 제공하는 뛰어난 헬퍼 메서드를 제대로 활용할 수 없다. 그렇다면 매개변수를 const std::string&로 지정하면 될까? 그렇게 하면 항상 std::string만 사용해야 한다. 예컨대 스트링 리터럴을 전달하면 컴파일러는 그 스트링 리터럴의 복사본이 담긴 string 객체를 생성해서 함수로 전달하기 때문에 오버헤드가 발생한다. 간혹 이러한 함수에 대해 오버로딩 버전을 여러 개 만들기도 하는데, 그리 세련된 방법은 아니다.

C++17부터 추가된 std::string_view 클래스를 사용하면 이런 고민을 해결할 수 있다. 이 클래스는 std::basic_string_view 클래스 템플릿의 인스턴스로서 <string_view> 헤더에 정의돼 있다. string_view는 실제로 const string& 대신 사용할 수 있으며 오버헤드도 없다. 다시 말해 스트링을 복사하지 않는다.

string_view 인터페이스는 c_str()이 없다는 점을 제외하면 std::string과 같다. data()는 똑같이 제공된다. string_view는 remove, prefix(size_t)와 remove_suffix(size_t)라는 메서드도 추가로 제공하는데, 지정한 오프셋만큼 스트링의 시작 포인터를 앞으로 당기거나 끝 포인터를 뒤로 미뤄서 스트링을 축소하는 기능을 제공한다.

참고로 string과 string_view를 서로 연결/결합할 수 있다. 예컨대 다음과 같이 작성하면 컴파일 에러가 발생하는데

string str = "Hello";
string_view sv = " world";
auto result = str + sv;

제대로 컴파일 하려면 마지막 줄을 다음과 같이 수정한다.

auto result = str + sv.data();

std::string을 사용할 줄 안다면 string_view의 사용법을 따로 배우지 않고도 곧바로 쓸 수 있다. 다음에 나온 extractExtension() 함수는 주어진 파일명에서 확장자만 뽑아서 리턴한다. 참고로 string_view는 대부분 값으로 전달(pass-by-value)한다. 스트링에 대한 포인터와 길이만 갖고 있어서 복사하는데 오버헤드가 적기 때문이다.

string_view extractExtension(string_view fileName)
{
return fileName.substr(fileName.rfind('.'));
}

함수를 이렇게 저으이하면 모든 종류의 스트링에 적용할 수 있다.

string fileName = R"(c:\temp\my_file.ext)";
cout << "C++ string: " << extractExtension(fileName) << endl;

const char* cString = R"(c:\temp\my_file.ext)";
cout << "C string: " << extractExtension(cString) << endl;

cout << "Literal: " << extractExtension(R"(c:\temp\my_file.ext)") << endl;

여기서 extractExtension()을 호출하는 부분에서 복제 연산이 하나도 발생하지 않는다. extractExtension() 함수의 매개변수와 리턴 타입은 단지 포인터와 길이만 타나낸다. 그래서 굉장히 효율적이다.

string_view 생성자 중에서 원시(raw) 버퍼와 길이를 매개변수로 받는 것도 있다. 이렇나 생성자는 NUL로 끝나지 않는 스트링 버퍼로 string_view를 생성할 때 사용한다. 또한 NUL로 끝나는 스트링 버퍼를 사용할 때도 유용하다. 하지만 스트링의 길이를 이미 알고 있기 때문에 생성자에서 문자 수를 따로 셀 필요는 없다.

const char* raw = /* ... */;
size_t length = /* ... */;
cout << "Raw: " << extractExtension(string_view(raw, length)) << endl;

string_view를 사용하는 것만으로 string이 생성되지는 않는다. string 생성자르 ㄹ직접 호출하거나 string_view::data()로 생성해야 한다. 예컨대 다음과 같이 const string&를 매개변수로 받는 함수가 있다고 하자.

void handleExtension(const string& extension) { /* ... */ }

이 함수를 다음과 같이 호출하면 제대로 작동하지 않는다.

handleExtension(extractExtension("my_file.ext"));

제대로 호출하려면 다음 두 방식 중 하나를 적용한다.

handleExtension(extractExtension("my_file.ext").data());  // data() 메서드
handleExtension(string(extractExtension("my_file.ext")));  // 명시적 ctor

Note) 읽기 전용 스트링을 받는 함수나 메서드의 매개변수 타입은 const std::string&나 const char* 대신 std::string_view로 지정한다.

std::string_view 리터럴

표준 사용자 정의 리터럴인 ‘sv’를 사용하면 스트링 리터럴을 std::string_view로 만들 수 있다.

auto sv = "My string_view"sv;

비표준 스트링

C++ 프로그래머 상당수가 C++ 스타일의 스트링을 사용하지 않는데는 여러 이유가 있다. C++ 규격에 명확히 나오지 않기 때문에 string이라는 타입이 있는 줄도 모르는 사람도 있고, C++ string이 만족스럽지 않아서 직접 정의해서 사용하는 프로그래머도 있고, 마이크로소프트 MFC의 CString 클래스처럼 개발 프레임워크나 운영체제에서 정의한 스트링을 사용하는 경우(주로 하위 호환성이나 레거시 문제를 해결하기 위해)도 있다.

C++ 프로젝트를 시작할 때 구성원이 사용할 스트링을 미리 결정하는 것은 굉장히 중요하다 그중에서도 다음 사항은 반드시 명심해야 한다.

  • C 스타일 스트링은 사용하지 않는다.
  • MFC나 QT 등에서 기본적으로 제공하는 스트링처럼 현재 사용하는 프레임워크에서 제공하는 스트링을 프로젝트의 표준 스트링으로 삼는다.
  • std::string으로 스트링을 표현한다면 함수의 매개변수로 전달할 읽기 전용 스트링은 std::string_view로 지정한다. 스트링을 다른 방식으로 표현한다면 현재 프레임워크에서 제공하는 string_view와 유사한 기능을 활용한다.
[ssba]

The author

Player가 아니라 Creator가 되려는 자/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.