[모던 C++] std::string_view, 왜 사용할까?

std::string_view 란

std::string_view 는 C++17부터 추가된, 문자열을 다루는데 아주 강력하고 효과적인 컨테이너입니다. 내부 구현은 다르겠지만, std::string_view의 동작과 기능은 std::span과 거의 비슷합니다. std::spanstd::vector, C-style array, std::array 등의 다양한 형태의 배열을 효율적으로 다루기 위해서 존재했다면, std::string_viewstd::string, const char *, char [] 와 같은 다양한 형태의 문자열을 효율적으로 다루기 위해서 존재합니다.

한편 std::span에 대해서 궁금하시다면, [모던 C++]std::span, 왜 쓰는 걸까?라는 글을 보고 오시면 도움이 될 것 같습니다.

기본적으로 std::string_view는 원본 문자열을 복사 생성 없이 그대로 참조하겠다는 성격이 강합니다. 그런데 이미 우리는 참조형이 있는데 왜 std::string_view가 필요할까요? 그 이유는 C++에는 std::string 외에 다양한 문자열 타입들이 있기 때문입니다.

std::string, const char *, char [] 의 차이점

일단 C++에는 앞서 말한 다양한 문자열의 형태가 존재하고, 모두가 그냥 std::string을 사용하면 좋겠지만, 성능, 사용성, 그리고 작업 환경 등에 따라서 std::string외에 다른 문자열 처리를 해야할 때가 있습니다(std::string은 추상화된 클래스이기 떄문에, 메모리 관리나 멤버 메소드 제공 등의 측면에서 편리하지만, 반대로 말하면 일반 const char * 등을 사용할 때보다 약간의 성능이나 메모리 사용 측면에서 단점이 발생합니다.) 또한 불행하게도 std::string, const char *, char [] 모두 내부 구현 동작 방식이 조금씩 다릅니다.

예를 들어서 std::string str1 = "script by";라고 선언하면, std::string형의 str1script by 라는 문자열을 가리키게 됩니다. 이때 script by라는 문자열은 그 크기에 따라서 힙 메모리 영역에 올라가냐 혹은 그냥 스택 메모리 영역에 올라가냐가 달라지는데, 문자열의 길이가 작을 경우 그냥 컴파일러가 알아서 스택 위에 올리고, 문자열의 길이가 크다면 컴파일러가 힙 메모리 영역 위에 올립니다.

한편 const char * str2 = "nx006";라고 선언하면, nx006라는 문자열은 Read only 메모리 영역(읽기 전용 메모리 영역)에 올라가게 됩니다. 그리고 str2는 Read only 메모리 영역에 있는 문자열을 가리키게 됩니다.

마지막으로 char[] str3 = "CODE EDGE;" 라고 선언하면, CODE EDGE라는 문자열은 stack 메모리 영역 위에 올라가게 됩니다. 그리고 str3가 이 문자열을 가리키게 됩니다.

기존 문자열 처리의 문제점

다음 코드를 볼게요. 이 코드는 아나그램에 대한 해시값을 생성하는 코드입니다.

// anagram hash generator
std::string anagram_hash(const std::string& str)
{
    std::map<char, int> alphabet_tables;
    std::ranges::for_each(str, [&alphabet_tables](const auto& c) {alphabet_tables[c]++; });
    std::string hash = "";
    for (const auto& [alphabet, number] : alphabet_tables) {
        hash += alphabet;
        hash += std::to_string(number);
    }

    return std::move(hash);
}

위 코드에서 인자값의 타입으로 const std::string& str을 받고 있습니다. 그리고 만약 main 함수에서 다음과 같이 문자열들을 넘겨준다고 가정해봅시다.

int main  ()
{
    std::string str1 = "AABBC";
    const char * str2 = "AABBC";
    char[] str3 = "AABBC";

    const auto hash1 = anagram_hash(str1);
    const auto hash2 = anagram_hash(str2);
    const auto hash3 = anagram_hash(str3);

    std::cout << hash1 << '\n'
              << hash2 << '\n'
              << hash3 << '\n';
    return 0;
}

결과:

A2B2C1
A2B2C1
A2B2C1

위 코드는 세 가지 입력값 str1, str2, str3에 대해서 동작을 하긴 합니다. 그러나 자세한 동작 과정을 살펴보시면, 의도하지 않은 동작이 이루어지고 있다는 것을 알 수 있습니다.

std::string str1에 대해선 문제 없이 작동합니다. 파라미터 타입이 참조형이기에 copy constructor 없이 인수를 함수에 넘겨줄 수 있습니다.
그런데 const char * str2char[] str3에서 문제가 생깁니다. 이를 넘겨줄 경우 내부적으로는 std::string형의 임시 객체가 만들어진 후 여기에 str2str3를 복사하는 Copy construction가 일어나게 됩니다. 그리고 복사된 메모리를 str이 가리키게 됩니다. 의도치 않은 복사 생성자가 호출되었고, 이로 인해 성능 저하가 발생하고 있습니다.

std::string_view의 사용

이를 해결하기 위해서 우리는 std::string_view를 통해 파라미터 타입을 조정할 수 있습니다.

std::string anagram_hash(const std::string_view str)
{
    std::map<char, int> alphabet_tables;
    std::ranges::for_each(str, [&alphabet_tables](const auto& c) {alphabet_tables[c]++; });
    std::string hash = "";
    for (const auto& [alphabet, number] : alphabet_tables) {
        hash += alphabet;
        hash += std::to_string(number);
    }

    return std::move(hash);
}

파라미터 타입을 std::string&에서 std::string_view로 바꾸어준다면, str2, str3를 사용하는 상황에서도 의도치 않은 임시 객체의 복사 생성 없이 우리는 원본 문자열을 바로 함수 내부에서 참조할 수 있습니다. std::string_view는 문법 이식이 간단하고 매우 강력한 기능이여서 적극적으로 활용할 수 있습니다.

std::string_view 사용 시 주의점

std::string_view는 참조형입니다. 즉 다시 말해서 원본이 변경되면, std::string_view도 변경됩니다. 만약 원본 메모리가 라이프 사이클이 끝나서 파괴된다면, std::string_view 역시 가비지 값을 가리키게 됩니다. 따라서 std::string_view 역시 다른 참조형과 마찬가지로 원본과 라이프 사이클이 어긋나지 않게 주의해야 합니다.