What’s Wrong with std::string

What’s Wrong with std::string

Jayden Yang
Jayden Yang

C/C++ 의 문자열 표준은 정말 오랫동안 사용되어 왔습니다. <string>, <string_view>, <string.h> 등 여러 헤더파일이 있고 유용한 메서드들이 많이 있는데요, 유용하게 사용해왔던건 맞지만 타 언어에 비해 지원하는 메서드나 기능이 부족한 것은 사실입니다.

이번 포수트에서는 std::string 의 문제점과 이를 해결하기 위한 다양한 기법들을 @ashvardanian 의 포스트 를 참고하여 살펴보겠습니다.

모호한 오버로딩

std::stringreplace 메서드의 프로토타입은 14가지나 있습니다.

아래 코드를 보고 결과를 바로 알 수 있나요?

   std::string("FC Bayern Munich").replace(1, 2, "123");
std::string("FC Bayern Munich").replace(1, 2, std::string("123"), 1);
std::string("FC Bayern Munich").replace(1, 2, "123", 1);
std::string("FC Bayern Munich").replace(1, 2, "123", 1, 1);
std::string("FC Bayern Munich").replace(1, 2, std::string("123"), 1, 1);
std::string("FC Bayern Munich").replace(1, 2, 3, 'a');
std::string("FC Bayern Munich").replace(1, 2, {'a', 'b'});

// and more..

아무리 경력이 많은 개발자라도 전부 알기는 힘들겠죠.

아래는 결과입니다.

   std::string("FC Bayern Munich").replace(1, 2, "123");
// F123Bayern Munich
std::string("FC Bayern Munich").replace(1, 2, std::string("123"), 1);
// F23Bayern Munich
std::string("FC Bayern Munich").replace(1, 2, "123", 1);
// F1Bayern Munich
std::string("FC Bayern Munich").replace(1, 2, "123", 1, 1);
// F2Bayern Munich
std::string("FC Bayern Munich").replace(1, 2, std::string("123"), 1, 1);
// F2Bayern Munich
std::string("FC Bayern Munich").replace(1, 2, 3, 'a');
// FaaaBayern Munich
std::string("FC Bayern Munich").replace(1, 2, {'a', 'b'});
// FabBayern Munich

오랜시간 표준이 업데이트 되면서 이런 오버라이딩이 쌓여왔죠. 메서드 뿐만 아니라 std::string 을 위한 서브클래스들도 추가되어왔습니다.

  • std::string_view
  • std::span

이 두 클래스는 문자열의 일부분을 참조하여 데이터 인스턴스가 중복되는 것을 방지하죠.

이렇게 문자열의 일부를 표현할 수 있는 클래스들이 있음에도, 왜 여전히 std::string 의 메서드들은 정수기반 range 를 받는 것만 지원하는 걸까요?

또, iterator 는 왜 사용하지 않을까요?

정수도 나쁜건 아닙니다만, 중요한 것은 정수 인자들에 대한 메서드의 처리입니다.

Out of bounds 처리

std::string 의 문자열의 특정 문자를 가져오는 메서드는 atoperator[] 가 있습니다.

   std::string str = "FC Bayern Munich";
std::cout << str[0] << std::endl;
std::cout << str.at(1) << std::endl;
``
```txt
F
C

두 메서드의 기능은 같아보이지만, 예외 처리에서 차이가 있습니다.

   std::string str = "FC Bayern Munich";
std::cout << "str[2013] = " << str[2013] << std::endl;
   str[2013] = // 실제로는 보이지 않지만 '\x00' 이 있음. 상황에 따라 '\x00' 이 아닌 다른 값이 있을 수 있음.

operator[] 는 인덱스가 범위를 벗어나면 undefined behavior 를 발생시킵니다. 즉 지시된 인덱스에 있는 메모리의 값을 반환합니다. 예외 처리가 없는거죠. 그렇기 때문에 빠르게 동작합니다.

   std::string str = "FC Bayern Munich";
std::cout << "str.at(2013) = " << str.at(2013) << std::endl;
   libc++abi: terminating due to uncaught exception of type std::out_of_range: basic_string

at 메서드는 인덱스가 범위를 벗어나면 std::out_of_range 예외를 던집니다. 그렇기 떄문에 느리죠.

내부적으로 try-catch 를 사용하여 예외 처리를 하고 있습니다.

벤치마크를 돌려보면…

   BM_StringOperatorBracket     714988 ns       714933 ns          980
BM_StringAt                 1489270 ns      1489178 ns          467

operator[] 가 더 빠른 것을 확인할 수 있습니다.

atoperator[] 의 차이가 예외 처리 여부라는 것을 알 수 있었습니다. 그럼 다른 함수는 어떨까요?

   std::string("Jamal Musiala").substr(6);
std::string("Jamal Musiala").substr(6, 100);
std::string("Jamal Musiala").substr(100);
std::string("Jamal Musiala").substr(20, 5);
std::string("Jamal Musiala").substr(-1, 5);
std::string("Jamal Musiala").substr(0, -1);

std::string::substr 은 해당 범위의 슬라이드를 반환합니다.

각 프로토타입이 예외처리를 어떻게 하는지 알 수 있나요?

   std::string("Jamal Musiala").substr(6)           = Musiala
std::string("Jamal Musiala").substr(6, 100)      = Musiala
std::string("Jamal Musiala").substr(100)         = std::out_of_range
std::string("Jamal Musiala").substr(20, 5)       = std::out_of_range
std::string("Jamal Musiala").substr(-1, 5)       = std::out_of_range
std::string("Jamal Musiala").substr(0, -1)       = Jamal Musiala

그다지 일관성이 없죠. 심지어는 첫번째 인자에만 경계 체크를 하고 두번째 인자에는 경계 체크를 하지 않습니다.

또한 substr(-1, 5) 의 경우 -1unsigned 로 변환되기까지 하는데 컴파일 옵션을 따로 두지 않는 한 경고도 없습니다.

저도 C++ 을 주력으로 삼는 개발자이지만, 사람들이 C++ 이 복잡하다고 생각하는 이유가 이런 것들 아닐까 싶습니다.

복잡한 템플릿, 모던 기법들까지 가기도 전에 이런 일관성 없고 오버로딩이 난무하는 것 부터 개발자에게 혼란을 주는 포인트가 되는 것 같습니다.

@ashvardanian 은 이 경우 Python 의 슬라이스 문법이 직관적이라고 주장하는데요, 저도 어느정도 동의합니다. Python 에서는 아래와 같은 표현이 가능하거든요.

   >>> "FC Bayern Munich"[1:3]
'C '
>>> "Jamal Musiala"[-5:]
'siala'
>>> "Jamal Musiala"[-4:-2]
'ia'

C++ 로 이 문법을 흉내 내자면 이렇게 할 수 있습니다.

   #include <iostream>
#include <string>

namespace jayden {

class string {
public:
    string(const char* str) {
        this->_str = std::string(str);
    }

    std::string operator[](std::initializer_list<int> range) const {
        auto it = range.begin();
        int first = *it++;
        int second = *it;

        int len = static_cast<int>(_str.length());
        if (first < 0) first += len;
        if (second < 0) second += len;
        if (first > second) return "";

        std::string result;
        for (int index = first; index < second; index++) {
            result += _str[index];
        }
        return result;
    }

private:
    std::string _str;
};

string operator "" _jayden(const char* str, unsigned long)
{
    return string(str);
}

} // namespace jayden

int main() {
    using namespace jayden;
    std::cout << "Jamal Musiala"_jayden[{-4, -2}] << std::endl;
}
   ia

여러 예외 처리가 빠진 미완 코드지만, C++ 에서 literal operator, std::initializer_list 를 사용하여 파이썬을 어느정도 흉내낼 수 있었습니다.

다른 기능도 살펴보겠습니다.

split

   >>> "FC Bayern Munich".split()
['FC', 'Bayern', 'Munich']
>>> "FC Bayern Munich".split("B")
['F', 'ayern Munich']

split 은 delimiter 를 기준으로 문자열을 분리하는 기능입니다. Python 으로 코드를 작성하다보면 반드시 사용하게되는 유용한 함수인데요, C/C++ 에서는 어떨까요?

C 표준에 strtok 함수가 있긴 하지만, thread-safe 하지 않습니다. 내부적으로 static 변수를 사용하기 떄문입니다.

   const char* str = "FC Bayern Munich";
char* token = strtok(str, " ");
while (token != NULL) {
    printf("%s\n", token);
    token = strtok(NULL, " ");
}

C++ 에서는 std::stringstreamstd::getline 을 사용하는 방법이 있습니다.

   std::stringstream ss("FC Bayern Munich");
std::string token;
while (std::getline(ss, token, ' ')) {
    std::cout << token << std::endl;
}

하지만 너무 번거롭고 복사도 발생해서 굉장히 느립니다.

C++23 에서는 std::ranges::views::split 이 추가되어 복사 없는 문자열 분리가 가능해졌습니다.

   std::string text = "one,two,three,four";
for (const char& ch : text) {
    std::cout
        << ch
        << " -> "
        << std::hex << static_cast<const void*>(&ch)
        << std::endl;
}

for (auto part : std::ranges::views::split(text, ',')) {
    std::cout 
        << std::hex << static_cast<const void*>(&*part.begin()) 
        << " -> "
        << *part.begin()
        << std::endl;
}
   o -> 0x16f6d2850
n -> 0x16f6d2851
e -> 0x16f6d2852
, -> 0x16f6d2853
t -> 0x16f6d2854
w -> 0x16f6d2855
o -> 0x16f6d2856
, -> 0x16f6d2857
t -> 0x16f6d2858
h -> 0x16f6d2859
r -> 0x16f6d285a
e -> 0x16f6d285b
e -> 0x16f6d285c
, -> 0x16f6d285d
f -> 0x16f6d285e
o -> 0x16f6d285f
u -> 0x16f6d2860
r -> 0x16f6d2861
0x16f6d2850 -> o
0x16f6d2854 -> t
0x16f6d2858 -> t
0x16f6d285e -> f

text 변수의 데이터가 복사되지 않고 참조 기반 split 이 이루어져 빨리 동작하게 되었고, 코드도 훨씬 직관적으로 작성할 수 있게 되었습니다.

특수한 케이스에서 더 빠르게 동작 시킬 수 있는데, 바로 lazy_split 을 사용하는 겁니다.

lazy_split 은 문자열을 한번에 분리하지 않고 필요할 때마다 분리하는 방식입니다.

   for (auto part : std::ranges::views::lazy_split(text, ',')) {
    ...
    if (some_condition) {
        break;
    }
    ...
}

이걸 사용하면 loop 중간에 탈출해야할 때 나머지 부분을 split 하지 않으므로 더 빠르게 동작할 수 있습니다.

그럼 이제 여러 기능들에 대한 벤치마크를 해봅시다.

   #include <benchmark/benchmark.h>
#include <sstream>
#include <string>
#include <vector>
#include <cstring>
#include <boost/algorithm/string.hpp>
#include <ranges>

// Helper: stringstream split
std::vector<std::string> split_stringstream(const std::string& s, char delim) {
    std::vector<std::string> result;
    std::stringstream ss(s);
    std::string item;
    while (std::getline(ss, item, delim)) {
        result.push_back(item);
    }
    return result;
}

// Helper: strtok split
std::vector<std::string> split_strtok(const std::string& s, char delim) {
    std::vector<std::string> result;
    char* cstr = new char[s.size() + 1];
    std::strcpy(cstr, s.c_str());
    char delim_str[2] = {delim, 0};
    char* token = std::strtok(cstr, delim_str);
    while (token) {
        result.push_back(token);
        token = std::strtok(nullptr, delim_str);
    }
    delete[] cstr;
    return result;
}

// Helper: boost::algorithm::split
std::vector<std::string> split_boost(const std::string& s, char delim) {
    std::vector<std::string> result;
    boost::split(result, s, boost::is_any_of(std::string(1, delim)));
    return result;
}

std::vector<std::string> split_ranges(const std::string& s, char delim) {
    std::vector<std::string> result;
    auto split_view = std::ranges::views::split(s, delim);
    for (auto&& part : split_view) {
        result.emplace_back(std::string(part.begin(), part.end()));
    }
    return result;
}

static void BM_StringStream(benchmark::State& state) {
    std::string text = "one,two,three,four,five,six,seven,eight,nine,ten";
    for (auto _ : state) {
        auto v = split_stringstream(text, ',');
        benchmark::DoNotOptimize(v);
    }
}
BENCHMARK(BM_StringStream);

static void BM_Strtok(benchmark::State& state) {
    std::string text = "one,two,three,four,five,six,seven,eight,nine,ten";
    for (auto _ : state) {
        auto v = split_strtok(text, ',');
        benchmark::DoNotOptimize(v);
    }
}
BENCHMARK(BM_Strtok);

static void BM_Boost(benchmark::State& state) {
    std::string text = "one,two,three,four,five,six,seven,eight,nine,ten";
    for (auto _ : state) {
        auto v = split_boost(text, ',');
        benchmark::DoNotOptimize(v);
    }
}
BENCHMARK(BM_Boost);

static void BM_RangesSplit(benchmark::State& state) {
    std::string text = "one,two,three,four,five,six,seven,eight,nine,ten";
    for (auto _ : state) {
        auto v = split_ranges(text, ',');
        benchmark::DoNotOptimize(v);
    }
}
BENCHMARK(BM_RangesSplit);

BENCHMARK_MAIN();
BenchmarkTime (ns)Time (ns)Iterations
BM_StringStream4784781,476,828
BM_Strtok3883881,793,534
BM_Boost5375371,251,385
BM_RangesSplit2972972,401,372

역시 참조 기반 split 이 가장 빠르고 직관적인 코드를 작성할 수 있었습니다.

정리

이처럼 C++ 표준 기능에는 유용한 것도 있고, 문제가 있는 것도 있습니다. 대형 회사들은 거의 모두 직접 해시테이블을 짜거나, 대안 오픈소스를 사용합니다.

std::string 도 예외가 아니죠.

일부 연산은 LibC를 호출하는데, LibC는 전반적으로 훨씬 최적화되어 있지만 하드웨어 성능을 완전히 뽑지 못하고, C++ 클래스가 요구하는 기능도 다 커버하지 못합니다.

STL 과 C++ 표준을 맹신하지 말고, 필요한 경우 대안 오픈소스를 사용하는 거나 직접 구현하는 것도 좋은 방법이라는 것을 알 수 있었습니다.