“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::split | getline | find/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++23 — string_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=value나 Header: 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이 아니라 파서의 일이다.
참고
- Abseil Tip of the Week #10: Splitting Strings, not Hairs — 분리 결과를 호출자가 원하는 컨테이너 타입에 맞춰 주는 API 설계, 복사 없이 뷰로 받는다는 발상, 여러 구분자를 받는 인터페이스를 참고했다. 이 팁이 소개하는
absl::StrSplit은 Abseil 라이브러리 함수라 이 글에서는 다루지 않았다. - cppreference — std::ranges::split_view — 두 번째 인자가 부분수열로 취급된다는 것과 부분 범위를 돌려준다는 것을 확인했다.
- cppreference — std::ranges::to — C++23 도입이라는 것을 확인했다.
- cppreference — std::basic_string_view::find_first_of — 문자 집합 중 하나를 찾는 동작을 확인했다.