“C++에는 split이 없다”는 말을 자주 듣는다. 정확히는 반쪽만 맞다. strtok은 C 시절부터 표준이었고, 공백으로 자르는 거라면 istringstream>>를 쓰면 된다. 없었던 건 문자열과 구분자를 주면 컨테이너를 돌려주는 전용 함수다. C++20의 std::views::split이 나오기 전까지는.

그래서 다들 각자 만들어 썼는데, 여기서 재미있는 일이 벌어진다. 각자 만든 것들이 서로 다른 답을 낸다. g++ 13.2(libstdc++)로 하나씩 돌려본 기록이다.

흔히 쓰는 세 가지

istringstream + getline

std::vector<std::string> split(const std::string& s, char d) {
  std::vector<std::string> out;
  std::string tok;
  std::istringstream is(s);
  while (std::getline(is, tok, d)) out.push_back(tok);
  return out;
}

find + substr

std::vector<std::string> split(std::string_view s, char d) {
  std::vector<std::string> out;
  size_t pos = 0, next;
  while ((next = s.find(d, pos)) != std::string_view::npos) {
    out.emplace_back(s.substr(pos, next - pos));
    pos = next + 1;
  }
  out.emplace_back(s.substr(pos));   // 마지막 조각
  return out;
}

마지막 emplace_back이 핵심이다. 빼먹으면 끝 조각이 통째로 사라진다.

C++20 std::views::split

for (auto part : std::views::split(sv, ','))
  out.emplace_back(part.begin(), part.end());

나란히 돌려보면 답이 갈린다

같은 입력을 셋에 넣어봤다.

입력views::splitgetlinefind/substr
"a,b,c"[a][b][c][a][b][c][a][b][c]
"a,,c" (중간이 빔)[a][][c][a][][c][a][][c]
"a,b," (끝이 구분자)[a][b][][a][b][a][b][]
"" (빈 문자열)0개0개[] 1개

어긋나는 방향이 서로 다르다.

끝이 구분자일 때는 getline만 다르다. 스트림이 구분자를 읽은 뒤 남은 입력이 없으면 그대로 끝내기 때문에 마지막 빈 필드가 사라진다. CSV로 치면 "a,b,"는 필드가 3개인데 2개를 받는다.

빈 문자열에서는 반대로 find/substr만 다르다. 루프를 한 번도 안 돌고 마지막 emplace_back으로 빈 조각 하나를 넣기 때문이다.

그래서 “올바른” split이 없다

이게 핵심이다. 셋 중 누가 맞는가?

  • "a,b,"가 3필드인가 2필드인가 — 쓰는 쪽에 달렸다. 빈 값을 허용하는 CSV라면 3개가 맞고, 공백으로 토큰을 자르는 중이라면 2개가 자연스럽다.
  • ""를 나누면 0개인가 빈 것 1개인가 — 이것도 답이 하나가 아니다.

파이썬은 이 문제를 인자로 갈랐다. "a,b,".split(",")는 3개를 주고 "a b ".split()는 2개를 준다. 구분자를 명시하면 빈 필드를 살리고, 생략하면 공백 여러 개를 묶어서 버린다. 동작이 다른 두 함수를 이름 하나에 겹쳐 놓은 것이다.

C++가 오래 표준 split을 못 가진 이유도 여기 있다. 하나의 옳은 의미가 없는데 표준 함수는 의미를 하나로 정해야 한다. 그러니 어떤 방법을 쓰든 그것이 경계에서 어떻게 구는지 알고 써야 한다. 위 표가 그 답이다.

views::split이 못 하는 것 — 여러 구분자

쉼표든 세미콜론이든 만나면 자르고 싶은 경우가 있다. 직관적으로 이렇게 쓰게 되는데,

std::views::split(in, std::string_view(",;"))   // "a,b;c" 를 넣으면?

결과는 [a,b;c]다. 하나도 안 쪼개진다.

views::split의 두 번째 인자는 **문자 집합이 아니라 부분수열(subsequence)**이다. 즉 ",;"는 “쉼표 다음에 바로 세미콜론이 오는 두 글자”를 뜻하고, 입력에 그런 자리가 없으니 통째로 하나가 된다. 여러 글자 구분자("=>" 같은)를 지원하는 것과 여러 개의 구분자 후보를 지원하는 것은 다른 이야기다.

이때는 find_first_of를 쓴다. 앞의 find 판에서 함수 이름만 바꾸면 된다.

while ((next = s.find_first_of(",;", pos)) != std::string_view::npos) {
  out.emplace_back(s.substr(pos, next - pos));
  pos = next + 1;
}
out.emplace_back(s.substr(pos));

"a,b;c"[a][b][c]. 이 흔한 요구를 표준 views::split으로는 못 한다.

공백으로 자르기는 또 다른 문제

공백 분리는 구분자 분리와 다르다. 보통 연속된 공백을 하나로 묶고 싶기 때문이다.

// "a  b" (공백 2개) 를 나누면
getline(is, tok, ' ')   →  [a][][b]    빈 조각이 낀다
is >> tok               →  [a][b]      연속 공백을 묶는다

그래서 공백이라면 구분자를 넘기는 게 아니라 그냥 >>를 쓴다.

std::vector<std::string> words;
std::string w;
std::istringstream is(line);
while (is >> w) words.push_back(w);

앞뒤 공백도 알아서 건너뛴다. 파이썬의 인자 없는 split()에 해당하는 것이 이쪽이다.

views::split에서 결과 꺼내기

views::split이 주는 건 std::string이 아니라 부분 범위다. 컨테이너에 담으려면 한 번 옮겨야 하고, 이 번거로움이 표준 버전에 따라 줄어든다. 각각 직접 컴파일해 확인했다.

C++20 — 포인터와 길이를 손으로 꺼낸다.

std::string_view piece(std::to_address(part.begin()), std::ranges::distance(part));

C++23string_view에 범위 생성자가 생겨 그냥 넘기면 된다.

out.emplace_back(part);   // C++20에서는 컴파일 에러

std::ranges::to까지 있으면 비로소 한 줄이다.

auto parts = s | std::views::split(',')
               | std::ranges::to<std::vector<std::string>>();

다만 ranges::to는 컴파일러 지원이 늦다. g++ 13.2는 -std=c++23을 줘도 이렇게 답한다.

error: 'to' is not a member of 'std::ranges'

__cpp_lib_ranges_to_container 매크로 자체가 없다. GCC 14부터다. 그전까지는 루프를 돈다.

복사 없이 받기

원본이 살아 있는 동안만 조각을 보면 되는 경우가 많다. 그러면 std::string으로 복사할 이유가 없다.

60자짜리 조각 3개를 나눌 때 할당 횟수를 세어봤다.

받는 타입할당
std::vector<std::string_view>3회
std::vector<std::string>6회

3회 차이가 정확히 문자열 복사 3번이다(60자라 SSO를 넘는다). 나머지 3회는 양쪽 다 vector가 커지며 생긴다.

대신 조각들이 원본 메모리를 그대로 들여다보므로 수명 규칙이 따라붙는다. 원본이 조각보다 오래 살아야 한다.

한 번만 자르는 경우

key=valueHeader: value처럼 첫 구분자에서 한 번만 자르면 되는 경우가 의외로 많다. 이때 split을 부르는 건 과하다.

auto pos = line.find('=');
if (pos != std::string_view::npos) {
  auto key   = line.substr(0, pos);
  auto value = line.substr(pos + 1);
}

값 안에 =가 또 있어도 안전하다는 게 요점이다. 전부 나눈 뒤 다시 붙이는 코드를 종종 보는데 그럴 필요가 없다.

그래서 뭘 쓰나

상황선택
여러 구분자 중 아무거나 (, 또는 ;)find_first_of. views::split은 못 한다
구분자가 여러 글자 (=>)views::split
공백으로 자르고 연속 공백을 묶고 싶다is >> tok
끝의 빈 필드가 의미 있다views::split 또는 find/substr
첫 구분자에서 한 번만find + substr 두 번
조각을 원본 수명 안에서만 본다views::split + string_view

views::split이 무조건 정답은 아니다. 여러 구분자를 못 다루고, 결과를 담으려면 ranges::to가 깔릴 때까지 루프가 필요하다. 지금 시점에서 가장 두루 쓸 만한 건 여전히 find 계열이고, views::split은 여러 글자 구분자나 지연 평가가 필요할 때 값어치를 한다.

마지막으로 하나. 진짜 CSV를 다룬다면 여기 있는 어느 것도 쓰면 안 된다. 따옴표로 감싼 필드 안의 쉼표("a,b",c)나 줄바꿈을 처리해야 하는데, 그건 split이 아니라 파서의 일이다.

참고