본문 바로가기
프로그래밍/C & C++

[C++] string과 string_view

by 별준 2022. 2. 6.

References

Contents

  • C-Style Strings
  • String Literals
  • Raw String Literals
  • C++ std::string 클래스
  • std::string_view 클래스
  • Nonstandard Strings

C 언어에서는 단순히 널(NULL)로 끝나는 문자 배열로 스트링을 표현했습니다. 하지만 이렇게 하면 버퍼 오버플로우(buffer overflow)를 비롯한 다양한 문제들 때문에 보안 취약점이 드러날 수 있습니다. C++ 표준 라이브러리에서는 이러한 문제를 방지하기 위해 보다 안전하고 사용하기 쉬운 std::string 클래스를 제공합니다. 이번 포스팅에서는 C++에서 제공하는 string에 대해 알아보도록 하겠습니다.

 


C-Style Strings

C언어는 string을 문자 배열로 표현합니다. string의 마지막에 널 문자(\0)를 붙여서 스트링이 끝났음을 표현합니다. 이러한 널 문자에 대한 공식적으로 NUL이며, 여기서 L은 두 개가 아닌 하나이며 NULL 포인터와는 다른 값입니다. C++에서 제공하는 문자열(string)이 훨씬 더 뛰어나지만 C언어에서 사용하는 string이 아직까지 많이 사용되기 때문에 간단하게 살펴보고 넘어가도록 하겠습니다.

 

C String을 다룰 때 가장 많이 하는 실수는 \0 문자를 담을 공간을 깜박하고 할당하지 않는 것입니다. 예를 들어, 'hello'라는 문자열을 구성하는 문자는 5개이지만, 메모리에 저장할 때는 문자 여섯개만큼의 공간이 필요합니다.

 

C++에서는 C언어에서 사용하던 문자열 연산에 대한 함수들도 제공합니다. 이러한 함수는 <cstring> 헤더 파일에 정의되어 있습니다. 여기에 정의된 함수들은 대체로 메모리 할당 기능을 제공하지는 않습니다. 예를 들면, strcpy() 함수는 string 타입의 매개변수 두 개를 받아서 두 번째 string을 첫 번째 string에 복사합니다. 이때, 두 string의 길이가 같은지 확인하지 않습니다.

 

다음 코드는 이미 메모리에 할당된 string을 매개변수를 받지 않고, 주어진 string에 딱 맞게 메모리를 할당한 결과를 리턴하는 함수를 strcpy()에 대한 래퍼 함수 형태로 구현한 예제 코드입니다. 여기서 string의 길이는 strlen() 함수로 구합니다. 그리고 copyString()에서 할당한 메모리는 이 함수를 호출한 쪽에서 해제해주어야 합니다.

char* copyString(const char* str)
{
	char* result = new char[strlen(str)];
    strcpy(result, str);
    
    return result;
}

위 함수에는 오류가 하나 있습니다. strlen() 함수에서 리턴하는 값은 string을 저장하는데 사용된 메모리의 크기가 아닌 string의 길이입니다. 따라서 strlen()은 'hello'라는 문자열에 대해 6이 아닌 5라는 값을 리턴합니다. 따라서 string에 저장하는데 필요한 메모리를 제대로 할당하려면 리턴받은 문자 수에 1을 더한 크기로 지정해주어야 합니다. 항상 +1이 붙어서 지저분하지만, C 스타일의 문자열을 다룰 때 항상 이를 명심해야 합니다. 위 코드의 오류를 수정하면 다음과 같이 작성됩니다.

char* copyString(const char* str)
{
	char* result = new char[strlen(str) + 1];
    strcpy(result, str);
    
    return result;
}

 

strlen() 함수가 문자열에 담긴 실제 문자의 수만 리턴하도록 구현된 이유는 여러 string들을 조합하여 하나의 string을 할당하는 경우를 생각해보면 이해가 쉽습니다. 예를 들어, 인수로 받은 string 3개를 하나로 합쳐서 리턴하는 함수를 생각해봅시다. 이때 리턴하는 string에 딱 맞게 공간을 할당하려면 세 string의 길이를 strlen() 함수로 구해서 모두 더한 값에 마지막 '\0'문자에 대한 공간 하나를 추가해주어야 합니다. 만약 strlen() 함수가 '\0'을 포함한 길이를 리턴하도록 구현되었다면 메모리 공간에 딱 맞게 계산하기가 번거롭습니다.

아래 코드는 방금 설명한 작업을 strcpy()와 strcat() 함수를 사용하여 구현한 함수를 보여줍니다.

char* appendStrings(const char* str1, cosnt char* str2, const char* str3)
{
	char* result = new char[strlen(str1) + strlen(str2) + strlen(str3) + 1];
    strcpy(result, str1);
    strcat(result, str2);
    strcat(result, str3);
    
    return result;
}

 

C와 C++에서 제공하는 sizeof() 연산자는 데이터 타입이나 변수의 크기를 구하는데 사용됩니다. 예를 들어, sizeof(char)는 1을 리턴하는데, char의 크기가 1바이트이기 때문입니다. 하지만 C 스타일의 string에 적용할 때는 sizeof()와 strlen()의 결과가 전혀 다릅니다. 따라서 string의 길이를 구할 때는 절대로 sizeof()를 사용하면 안됩니다. sizeof()의 리턴값은 C 스타일 string이 저장된 방식에 따라 다르기 때문입니다.

예를 들어, 다음과 같이 string을 char[]로 저장하면 sizeof()는 '\0'를 포함하여 이 string에 대해 실제로 할당된 메모리 크기를 리턴합니다.

char text1[] = "abcdef";
size_t s1 = sizeof(text1);
size_t s2 = strlen(text1);
std::cout << "sizeof(text1): " << s1 << " / strlen(text1): " << s2 << std::endl;

 

반면 C 스타일로 string을 char*로 저장했다면 sizeof()는 포인터의 크기를 리턴합니다.

const char* text2 = "abcdef";
size_t s3 = sizeof(text2);
size_t s4 = strlen(text2);
std::cout << "sizeof(text2): " << s3 << " / strlen(text2): " << s4 << std::endl;

32비트 모드에서 실행했기 때문에 sizeof(text2)의 값이 4로 출력됩니다. 만약 64비트 모드였다면 8로 출력됩니다.

 


String Literals

C++에서는 String을 인용부호("")로 묶어서 사용할 때가 있습니다. 예를 들어, 다음의 코드는 hello라는 문자열을 변수에 저장하지 않고 문자열값을 바로 출력합니다.

std::cout << "hello" << std::endl;

여기서 'hello'처럼 변수에 저장하지 않고 곧바로 값으로 표현한 문자열을 String Literal(문자열 리터럴)이라고 합니다. 문자열 리터럴은 내부적으로 메모리의 읽기 전용 영역에 저장됩니다. 그래서 컴파일러는 같은 문자열 리터럴이 코드에 여러 번 사용되면 한 문자열에 대한 레퍼런스를 재사용하는 방식으로 메모리를 절약합니다. 즉, 코드에서 'hello'란 문자열 리터럴을 여러번 사용해도 컴파일러는 hello에 대한 메모리 공간을 딱 하나만 할당합니다. 이를 리터럴 풀링(literal pooling)이라고 합니다.

 

문자열 리터럴을 변수에 대입(assigned)할 수는 있지만, 문자열 리터럴을 읽기 전용 메모리에 위치하며 리터럴 풀링의 가능성 때문에 변수에 대입하는 것은 위험합니다. C++ 표준에서는 공식적으로 문자열 리터럴을 'n개의 const char 배열' 타입으로 정의하고 있습니다. 하지만 const가 없었던 시절에 작성된 레거시 코드의 하위 호환성을 보장하기 위해서 문자열 리터럴을 cosnt char*가 아닌 타입으로 저장하는 컴파일러도 많습니다. const가 없이 char* 타입 변수에 문자열 리터럴을 대입하더라도 그 값을 변경하지 않는 한 프로그램 실행에는 아무런 문제가 없지만, 문자열 리터럴을 수정하는 동작에 대해서는 정의되어 있지 않습니다. 따라서 프로그램이 갑자기 죽을 수도 있고 실행은 되지만 겉으로 드러나지 않는 문제점들이 발생할 수 있으며 무시되거나 의도한 대로 동작될 수도 있습니다. (구체적인 동작은 컴파일러마다 다릅니다.)

 

따라서, 다음과 같이 코드를 작성하면 결과를 예측할 수 없습니다.

char* ptr = "hello";
ptr[1] = 'a';

(Visual Studio 2019에서는 첫 번째 줄의 대입에서부터 에러가 발생합니다.)

 

문자열 리터럴을 참조할 때는 const char에 대한 포인터를 사용하는 것이 훨씬 안전합니다.

const char* ptr = "hello";
ptr[1] = 'a'; // error

위의 코드도 똑같은 버그를 담고 있긴 하지만, 문자열 리터럴을 const char* 타입 변수에 대입했기 때문에 컴파일러는 읽기 전용 메모리에 쓰기 작업을 하는 것을 걸러낼 수 있습니다.

 

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

char arr[] = "hello";
arr[1] = 'a';

 


Raw String Literals

Raw String Literals는 여러 줄에 걸쳐 작성한 문자열 리터럴이며, 이 안에 담긴 인용부호("")를 이스케이프 시퀀스로 표현할 필요가 없으며 \t나 \n과 같은 이스케이프 문자를 일반 텍스트로 취급합니다.

(여기저기 찾아보니 Raw String Literals를 원시 문자열 리터럴이라고 번역하고 있습니다.)

 

예를 들어, 일반 문자열 리터럴을 다음과 같이 작성하면 문자열 안에 있는 큰따옴표를 이스케이프 시퀀스로 표현하지 않았기 때문에 컴파일 에러가 발생합니다.

const char* str = "Hello "World"!"; // error

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

const char* str = "Hello \"World\"!"; // no error

하지만, Raw 문자열 리터럴을 사용하면 인용부호를 이스케이프 시퀀스로 표현하지 않아도 됩니다. Raw 문자열 리터럴은 R"(로 시작하여 )"로 끝이 납니다.

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

 

Raw 문자열 리터럴을 사용하지 않고, 여러 줄에 걸친 문자열을 표현하려면 문자열 안에서 줄이 바뀌는 지점에 '\n'을 넣어주어야 합니다.

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

이를 Raw 문자열 리터럴로 표현할 때는 다음과 같이 줄바꿈할 지점에 '\n'를 입력하는 것이 아니라 그냥 엔터키를 누르면 됩니다. 그러면 '\n'를 사용했을 때와 똑같이 출력됩니다.

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

이처럼 Raw 문자열 리터럴에서는 이스케이프 문자를 무시합니다. 예를 들어, 다음과 같이 작성하면 \t 이스케이프 시퀀스가 탭 문자로 바뀌지 않고 그대로 출력합니다.

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

 

또한 Raw 문자열 리터럴은 )"로 끝나기 때문에 그 안에 )"를 넣을 수가 없습니다. 따라서 다음의 코드는 에러가 발생합니다.

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

)"를 추가하려면 다음과 같이 확장된 Raw 문자열 리터럴(extended raw string literal)로 표현해주어야 합니다.

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

여기서 r-char-sequence에 해당하는 부분이 실제 Raw 문자열 리터럴입니다. d-char-sequence라고 표현된 부분은 구분자 시퀀스(delimeter sequence)로서, 반드시 Raw 문자열 리터럴의 시작과 끝에 똑같이 작성해주어야 합니다. 이전에는 d-char-sequence에 아무것도 작성하지 않았던 것과 같습니다. 이 구분자 시퀀스는 최대 16개의 문자를 가질 수 있으며, Raw 문자열 리터럴에 나오지 않은 값으로 지정해주어야 합니다.

이를 사용하여, Raw 문자열 리터럴 내부에서 )"를 사용하도록 다음과 같이 작성할 수 있습니다.

const char* str = R"-(Embedded )" character)-"; // no error

 

참고로 Raw 문자열 리터럴을 사용하면 데이터베이스 쿼리 문자열이나 정규표현식, 파일 경로 등을 쉽게 표현할 수 있습니다.

 


C++ std::string 클래스

C++ 표준 라이브러리는 문자열을 조금 더 잘 표현하도록 std::string 클래스를 제공합니다. 엄밀히 말하자면 std::string은 basic_string이라는 클래스 템플릿의 인스턴스로서, <cstring>의 함수와 기능은 비슷하지만 메모리 할당 작업을 처리해주는 기능이 더 들어가 있습니다. string 클래스는 std 네임스페이스에 속하며 <string> 헤더에 정의되어 있습니다.

 

그럼 std::string에 대해 조금 더 자세히 살펴보도록 하겠습니다.

 

string 클래스 사용법

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

예를 들어, 다음과 같이 + 연산자는 문자열 결합(string concatenation) 연산을 수행하도록 재정의되어 있습니다.

std::string a{ "12" };
std::string b{ "34" };
std::string c;

c = a + b; // "1234"

마찬가지로 += 연산자는 쉽게 string을 뒤에 추가할 수 있도록 오버로딩되어 있습니다.

std::string a{ "12" };
std::string b{ "34" };

a += b // a == "1234";

 

+) std::string의 생성과 삭제에는 다음의 방법들을 사용할 수 있습니다.

 

문자열 비교

C 스타일의 문자열은 '==' 연산자로 비교할 수 없다는 단점이 있습니다.

char* a{ "12" };
char b[] { "12" };

if (a == b) {
  /* ... */
}

예를 들어, 위와 같은 두 개의 문자열이 있고, 두 문자열을 비교하는 문장을 작성하면 문자열의 내용이 아닌 포인터 값을 비교하기 때문에 항상 false가 리턴됩니다.

C언어에서는 배열과 포인터가 밀접하게 얽혀 있습니다. 위 코드에서 배열 b에서 b는 사실 배열의 첫 번째 원소를 가리키는 포인터입니다.

따라서, C언어에서 문자열을 비교하려면 다음과 같이 작성해주어야 합니다.

if (strcmp(a, b) == 0) {
  /* ... */
}

또한, <, <=, >=, >로 문자열을 비교할 수 없기 때문에 주어진 문자열을 사전식 나열 순서에 따라 비교하여 -1, 0, 1을 리턴하는 strcmp()를 사용합니다. 따라서 코드가 지저분해지고 읽기 어려울 뿐만 아니라 에러가 발생하기도 쉽습니다.

 

C++에서 제공하는 string에서는 ==, !=, <와 같은 연산자를 오버로딩하고 있어 쉽게 사용할 수 있습니다. 물론, C처럼 각각의 문자를 []로 접근할 수도 있습니다.

또한 C++ string 클래스는 strcmp()처럼 동작하는 compare() 메소드를 제공하며 비슷한 리턴 타입을 가지고 있습니다.

std::string a { "12" };
std::string b { "34" };

auto result { a.compare(b) };
if (result < 0) { std::cout << "less" << std::endl; }
if (result > 0) { std::cout << "greater" << std::endl; }
if (result == 0) { std::cout << "equal" << std::endl; }

여기서 result는 int 타입으로 추론됩니다. compare() 메소드는 strcmp()와 쓰는 것처럼 리턴 값의 대한 정확한 의미를 알아야하기 때문에 사용하기 번거로울 수 있습니다. 대신 연산자들을 사용하여 다음과 같이 사용할 수 있습니다.

std::string a{ "12" };
std::string b{ "32" };

if (a < b) { std::cout << "less" << std::endl; }
if (a > b) { std::cout << "greater" << std::endl; }
if (a == b) { std::cout << "equal" << std::endl; }

 

C++20 부터는 string 비교를 3-way 비교 연산자 <=>로 수행할 수 있습니다.

여기서 각 함수의 파라미터로 전달되는 cmp는 <=> 연산자의 결과입니다.

 

메모리 관리

다음 코드는 string 연산자를 사용할 때 메모리 관련 작업은 string 클래스에서 알아서 처리해준다는 것을 알 수 있습니다. 따라서 memory overrun을 걱정할 필요가 없습니다.

std::string myString{ "hello" };
myString += ", there";
std::string myOtherString{ myString };
if (myString == myOtherString) {
  myOtherString[0] = 'H';
}
std::cout << myString << std::endl;      // hello, there
std::cout << myOtherString << std::endl; // Hello, there

위 예제 코드에서 몇 가지 짚고 넘어갈 부분이 있습니다.

첫째, string을 할당하거나 크기를 변경하는 코드가 여기저기 흩어져 있더라도 메모리 누수가 발생하지 않습니다. 이는 string 객체가 모두 스택 변수로 생성되기 때문입니다. string 클래스를 사용하면 메모리를 할당하거나 크기를 변경하는 일이 상당히 많지만 string 객체가 생성된 스코프를 벗어나면 여기에 할당된 메모리는 string 소멸자가 알아서 정리합니다.

두번째는 연산자를 원하는 방식으로 동작하도록 할 수 있다는 것입니다. 예를 들어, = 연산자를 스트링을 복사하는데 사용하면 상당히 편리합니다.

 

C-Style 문자열과의 호환성

string 클래스에서 제공하는 c_str() 메소드를 사용하면 C언어에서 호환되는 const char 포인터를 얻을 수 있습니다. 그러나 리턴된 const 포인터는 string에 대한 메모리를 재할당하거나 해당 string 객체를 제거하면 유효하지 않습니다. 따라서, 현재 string에 담긴 내용을 정확히 사용하려면 이 메소드를 호출한 직후에 리턴된 포인터를 바로 활용하도록 작성하는 것이 좋습니다. 또한 함수 안에서 생성된 스택 기반의 string 객체에 대해서 c_str()을 호출한 결과를 절대로 리턴값으로 전달하면 안됩니다.

 

string에서 제공하는 data() 메소드는 C++14까지는 c_str()처럼 const char* 타입으로 값을 리턴했지만, C++17부터는 non-const string에 대해 data() 메소드를 호출하면 char* 타입을 리턴하도록 변경되었습니다.

 

Operations on strings

string 클래스는 꽤 많은 연산을 제공하는데 자주 사용되는 몇 가지만 소개하고 넘어가도록 하겠습니다.

C++에서 제공되는 모든 연산들은 link를 참조해주세요.

  • substr(pos, len) : 주어진 position에서부터 주어진 length까지의 substring을 리턴
  • find(str) : 주어진 substring을 찾았다면 그 위치를 리턴하고, 찾지 못했다면 string::npos를 리턴
  • replace(pos, len, str) : 주어진 pos 위치에서부터 len 길이의 문자열을 str로 교체

아래 코드는 위 연산들을 사용한 예제입니다.

std::string strHello{ "Hello!!" };
std::string strWorld{ "The World..." };
auto position{ strHello.find("!!") }:
if (position != std::string::npos) {
  // Found the "!!" substring, now replace it.
  strHello,replace(position, 2, strWorld.substr(3, 6));
}
std::cout << strHello << std::endl; // Hello World

 

std::string Literals

소스 코드에서 문자열 리터럴을 보통 const char*로 처리됩니다. 만약 표준 사용자 정의 리터럴 's'를 사용하면 문자열 리터럴을 std::string로 처리하도록 할 수 있습니다.

auto string1{ "Hello World" };  // string1 is a const char*.
auto string2{ "Hello World"s }; // string2 is an std::string.

(표준 사용자 정의 리터럴 : the standard user-defined literal)

 

표준 사용자 정의 리터럴 s는 std::literals::string_literals 네임스페이스에 정의되어 있습니다. 그러나 string_literals와 literals 네임스페이스 모두 인라인 네임스페이스(inline namespace)라고 불립니다. 따라서, 코드에서 이러한 문자열 리터럴을 사용하려면 다음 중의 하나를 작성해주어야 합니다.

using namespace std;
using namespace std::literals;
using namespace std::string_literals;
using namespace std::literals::string_literals;

 

참고로 std::vector는 class template argument deduction(CTAD)를 지원합니다. 간단히 말하자면, 초기화 리스트(initializer list)를 기반으로 vector의 타입을 자동으로 컴파일러가 추론한다는 것인데, 이를 이용하면 vector를 다음과 같이 작성할 수 있습니다.

std::vector names{ "Jonh", "Sam","Joe" };

위 코드에서 names는 vector<std::string>이 아닌 vector<const char*> 타입으로 추론됩니다.

만약 vector<string>으로 사용하고 싶다면, std::string 리터럴을 사용하여 다음과 같이 작성해주면 됩니다.

std::vector names{ "Jonh"s, "Sam"s,"Joe"s };

 

Numeric Conversions

C++ 표준 라이브러리는 high-level과 low-level의 숫자 변환 함수를 제공합니다.

 

High-Level Numeric Conversions

std 네임스페이스는 쉽게 숫자를 string으로, string을 숫자로 변환할 수 있는 헬퍼 함수들을 포함합니다. 이 함수들은 <string> 헤더에 정의되어 있습니다.

 

std::string to_string(T val);

위 함수는 숫자 값을 string 타입으로 변경하는데 사용할 수 있습니다.

여기서 T는

  • (unsigned) int
  • (unsigned) long
  • (unsigned) long long
  • float
  • double or long double

가 될 수 있습니다.

다음과 같이 사용할 수 있습니다.

long double d{ 3.14L };
std::string s{ std::to_string(d) }; // "3.140000"

 

반대로 문자열을 숫자로 변환하는 함수들이 std 네임스페이스에 정의되어 있습니다. 이 함수들에서 str은 변환하려는 원본 string 값을 의미하고, idx는 변환되지 않은 부분의 맨 처음 문자의 인덱스를 가리키는 포인터이고, base는 변환할 수의 밑(base, 기수)입니다. idx 포인터를 null 포인터로 설정하면 이 값은 무시됩니다. 이 함수들은 처음에 나타나는 공백 문자는 무시합니다. 이 함수를 실행했을 때, 변환에 실패하면 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);

다음은 예제 코드입니다.

#include <iostream>
#include <string>
#include <format>

int main(void) {
  const std::string toParse{ "   123USD" };
  size_t index{ 0 };
  int value{ stoi(toParse, &index) };
  std::cout << std::format("Parsed value: {}", value) << std::endl;
  std::cout << std::format("First non-parsed character: '{}'", toParse[index]) << std::endl;
  
  return 0;
}

여기서 std::format이 익숙하지 않으실텐데, 이는 C++20에서 새로 도입된 기능입니다.

 

stoi(), stol(), stoul(), stoll(), stoull()은 base라는 파라미터를 받는데, 이는 값이 표현되는 base를 지정합니다. 만약 기본값인 10이면 10진수라고 가정하고 0-9까지의 숫자로 변환합니다. 만약 16이라면 16진수라고 가정합니다. 만약 0으로 설정하면 위 함수들은 아래와 같은 방법으로 주어진 숫자의 base를 설정합니다.

  • 0x 또는 0X로 시작하면 16진수로 파싱
  • 0으로 시작하면 8진수로 파싱
  • 그 이외에는 10진수로 파싱

 

Low-Level Numeric Conversions

C++17부터는 low-level의 숫자 변환에 대한 함수도 다양하게 제공합니다. 이 함수들은 <charconv> 헤더에 정의되어 있습니다. 이 함수들은 메모리 할당에 관련된 작업들은 수행하지 않고 직접 std::string과 작업하지 않으며 호출한 쪽에서 raw 버퍼를 할당하는 방식으로 사용해야 합니다. 또한, 이 함수들은 고성능과 locale-independent를 위해 튜닝되었습니다. (locale에 관련한 내용은 추후에 알아보도록 하겠습니다.. !)

이러한 이유 때문에 다른 high-level 변환 함수에 비해 처리 속도가 엄청나게 빠릅니다. 또한 이 함수들은 소위 perfect round-tripping(?)을 위해 디자인되었는데, 이는 숫자값을 문자열 표현으로 serializing한 다음 결과값(문자열)을 숫자 값으로 다시 deserializing한 후 원래 값과 정확히 같은 값이 된다는 것을 의미합니다.

 

이 함수들은 높은 성능, perfect round-tripping, locale-independent conversions을 원하는 경우에 사용하면 됩니다.

 

정수를 문자로 변환하고 싶다면, 다음과 같은 함수를 사용하면 됩니다.

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

여기서 IntergerT에는 signed 또는 unsigned 정수 타입 또는 char가 될 수 있습니다. 그리고 리턴 타입인 to_chars_result는 다음과 같이 정의되어 있습니다.

struct to_chars_result {
  char* ptr;
  errc ec;
}

만약 변환이 성공했다면 ptr은 변환된 문자의 마지막 다음 위치를 가리키고, 실패했다면 last 값과 같습니다(이때 ec == errc::value_too_large입니다.).

다음은 to_chars 함수를 사용하는 예제 코드입니다.

#include <iostream>
#include <string>
#include <charconv>

int main(void) {
  const size_t BufferSize{ 50 };
  std::string out(BufferSize, ' ');
  auto result{ std::to_chars(out.data(), out.data() + out.size(), 12345) };
  if (result.ec == std::errc{}) {
    std::cout << out << std::endl; // "12345"
  }

  return 0;
}

C++17의 structured binding을 사용하면, 아래처럼 간단하게 작성할 수도 있습니다.

#include <iostream>
#include <string>
#include <charconv>

int main(void) {
  const size_t BufferSize{ 50 };
  std::string out(BufferSize, ' ');
  auto [ptr, error] { std::to_chars(out.data(), out.data() + out.size(), 12345) };
  if (error == std::errc{}) {
    std::cout << out << std::endl;
  }

  return 0;
}

 

위와 비슷하게, 부동소수점 타입에 대한 변환 함수도 제공합니다.

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 format, int precision);

여기서 FloatT는 float, double, long double이 될 수 있습니다. 포맷은 chars_format 플래그 조합으로 지정될 수 있습니다.

// ENUM CLASS chars_format
enum class chars_format {
  scientific = 0b001,
  fixed      = 0b010,
  hex        = 0b100,
  general    = fixed | scientific,
};

기본 포맷은 chars_format::general을 적용하면 to_chars()는 부동소수점값을 심진수 표기법인 (-)ddd.ddd와 십진수 지수 표기법인 (-)d.ddde±dd 중에서 소수점 왼쪽의 숫자를 표기할 때 전체 길이가 가장 짧은 형태로 변환됩니다. 포맷에 정밀도(precision)을 지정하지 않으면 주어진 포맷에서 가장 짧게 표현할 수 있는 형태로 결정되며, 정밀도의 최댓값은 여섯 자리입니다.

#include <iostream>
#include <string>
#include <charconv>

int main(void) {
  double value{ 0.314 };
  const size_t BufferSize{ 50 };
  std::string out(BufferSize, ' ');
  auto [ptr, error] { std::to_chars(out.data(), out.data() + out.size(), value) };
  if (error == std::errc{}) {
    std::cout << out << std::endl; // "0.314"
  }

  return 0;
}

 

반대로, 문자열을 숫자로 변환해주는 함수들도 있습니다.

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

여기서 from_chars_result는 다음과 같이 정의되어 있습니다.

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

ptr은 변환에 실패한 경우에 첫 번째 문자에 대한 포인터가 되며, 변환에 성공한다면 last와 같습니다. 변환된 문자가 하나도 없다면 ptr은 first와 같으며, 에러 코드는 errc::invalid_argument가 됩니다. 만약 파싱되는 값이 너무 커서 지정된 타입으로 표현할 수 없다면 에러 코드 값은 errc::result_out_of_range가 됩니다. 참고로 from_chars()는 앞에 나오는 공백을 무시하지 않습니다.

 

아래 예제 코드는 perfect round-tripping 특징을 잘 설명해주고 있습니다.

#include <iostream>
#include <string>
#include <charconv>

int main(void) {
  double value1{ 0.314 };
  const size_t BufferSize{ 50 };
  std::string out(BufferSize, ' ');
  auto [ptr1, error1] { std::to_chars(out.data(), out.data() + out.size(), value1) };
  if (error1 == std::errc{}) {
    std::cout << out << std::endl;
  }

  double value2;
  auto [ptr2, error2] { std::from_chars(out.data(), out.data() + out.size(), value2) };
  if (error2 == std::errc{}) {
    if (value1 == value2) {
      std::cout << "Perfect roundtrip\n";
    }
    else {
      std::cout << "No perfect roundtrip?!?\n";
    }
  }

  return 0;
}

 


std::string_view 클래스

C++17 이전에는 읽기 전용 문자열을 받는 함수의 매개변수 타입을 쉽게 결정할 수 없었습니다. const char*로 지정하면 std::string을 사용하는 클라이언트에서 c_str()이나 data()를 이용하여 string을 const char*로 변환해서 호출해야 합니다. 더 심각한 것은 이렇게 할 경우 std::string의 객체지향 속성과 여기서 제공되는 편리한 헬퍼 메소드를 제대로 활용할 수가 없습니다. 

그렇다면 const std::string&으로 매개변수를 지정하면 어떻게 될까요? 이렇게 하면 항상 std::string만 사용해야 합니다. 예를 들어 문자열 리터럴을 전달하면 컴파일러는 그 문자열 리터럴의 복사본이 담긴 string 객체를 생성하여 함수로 전달하기 때문에 오버헤드가 발생합니다. 종종 이러한 함수에 오버로딩 버전(const char*를 받는 것과 const 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)라는 메소드도 추가로 제공하는데, 지정한 offset만큼 문자열의 시작 포인터를 앞으로 당기거나 끝 포인터를 뒤로 미뤄서 문자열을 축소할 수 있습니다.

 

다음 예제 코드들을 살펴보면 알겠지만, std::string을 사용할 줄 안다면 std::string_view도 쉽게 사용할 수 있습니다. 아래의 extractExtension() 함수는 주어진 파일명에서 확장자만 추출하여 리턴합니다. 참고로 string_view는 대부분 값으로 전달(pass-by-value)합니다. 이는 string_view가 string에 대한 포인터와 길이만 포함하고 있기 때문에 복사하는데 오버헤드가 크지 않기 때문입니다.

std::string_view extractExtension(std::string_view filename)
{
  return filename.substr(filename.rfind('.'));
}

이렇게 함수를 구현하면, 모든 종류의 문자열 타입에 적용할 수 있습니다.

#include <iostream>
#include <string>
#include <string_view>

std::string_view extractExtension(std::string_view filename)
{
  return filename.substr(filename.rfind('.'));
}

int main(void) {
  std::string filename{ R"(c:\temp\my file.ext)" };
  std::cout << "C++ string: " << extractExtension(filename) << std::endl;

  const char* cString{ R"(c:\temp\my file.ext)" };
  std::cout << "C string: " << extractExtension(cString) << std::endl;
  std::cout << "Literal: " << extractExtension(R"(c:\temp\my file.ext)") << std::endl;

  return 0;
}

여기서 extractExtension()을 호출하는 부분에서 복사(copy) 연산이 하나도 발생하지 않습니다. 이 함수의 매개변수와 리턴타입은 단지 포인터와 문자열의 길이만을 나타내므로 굉장히 효율적입니다.

 

string_view 생성자에는 raw buffer와 길이를 매개변수로 받는 것도 있습니다. 이러한 생성자는 NUL로 끝나지 않는 문자열 버퍼로 string_view를 생성할 때 사용됩니다. 또한 NUL로 끝나는 문자열 버퍼를 사용할 때도 유용합니다. 하지만 문자열의 길이를 이미 알고 있기 때문에 생성자에서 문자 수를 따로 셀 필요는 없습니다.

#include <iostream>
#include <string>
#include <string_view>

std::string_view extractExtension(std::string_view filename)
{
  return filename.substr(filename.rfind('.'));
}

int main(void) {
  const char* raw = R"(c:\temp\my file.ext)";
  size_t length = strlen(raw);
  std::cout << "Raw: " << extractExtension({ raw, length }) << std::endl;

  return 0;
}

다음과 같이 extractExtension을 호출할 수도 있습니다.

std::cout << "Raw: " << extractExtension(std::string_view{ raw, length }) << std::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() method
handleExtension(std::string(extractExtension("my file.ext"))); // explicit ctor

 

이러한 이유로 string과 string_view를 결합할 수는 없습니다. 따라서 아래 코드는 컴파일 에러를 발생시킵니다.

std::string str{ "Hello" };
std::string_view sv{ " world" };
auto result{ str + sv };

대신 string_view의 data() 메소드를 사용하여 다음과 같이 하면 정상적으로 컴파일이 됩니다.

auto result1{ str + sv.data() };

또는 append() 를 사용하여 다음과 같이 사용해도 됩니다.

std::string result2{ str };
result2.append(sv.data(), sv.size());

 

std::string_view 와 Temporary Strings

string_view는 temporary string을 저장할 수 없습니다. 다음의 예제 코드를 살펴보겠습니다.

#include <iostream>
#include <string>
#include <string_view>

int main(void) {
  std::string s{ "Hello" };
  std::string_view sv{ s + " World!" };
  std::cout << sv;

  return 0;
}

위 코드는 정의되지 않은 동작을 수행합니다. 컴파일러나 컴파일러 세팅에 따라 다르겠지만, 저의 경우에는 아래처럼 출력합니다.

위 코드에서 sv는 "Hello World!"를 담고 있는 임시 문자열로 초기화됩니다. string_view는 이 임시 문자열의 포인터를 저장하게 됩니다. 그러나 line 7의 끝에서 임시 문자열은 없어지며, string_view에는 댕글링 포인터(dangling pointer)만 남게 됩니다.

 

std::string_view Literals

표준 사용자 정의 리터럴인 'sv'를 사용하면 문자열 리터럴을 std::string_view로 처리할 수 있습니다.

auto sv{ "My string_view"sv };

이 표준 사용자 정의 리터럴 'sv'를 사용하려면 다음 중 하나의 using 디렉티브를 사용해야 합니다.

using namespace std::literals::string_view_literals;
using namespace std::string_view_literals;
using namespace std::literals;
using namespace std;

 


Non-standard Strings

C++ 프로그래머가 C++ 스타일의 문자열을 사용하지 않는 여러 이유가 있습니다. 어떤 프로그래머는 string이 항상 C++ 스펙의 한 부분이 아니었기 때문에 단순히 string 타입을 알지 못하는 경우가 있습니다. 또 다른 프로그래머는 수년간 C++ 문자열을 사용하다가 만족하지 않아서 원하는 형태로 문자열 타입을 직접 정의해서 쓰는 경우도 있습니다. 

아마 가장 큰 이유는 마이크로소프트 MFS의 CString 클래스처럼 개발 프레임워크나 운영체제에서 나름대로 정의한 문자열을 제공하기 때문입니다. 주로 하위 호환성이나 레거시 문제를 해결하기 위해서 이렇게 제공합니다. C++로 프로젝트를 시작할 때 구성원들이 사용할 문자열을 미리 결정하는 것은 굉장히 중요하며, 그중에서도 다음 사항은 반드시 명심해야 합니다.

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

 

댓글