문자열을 반복문 안에서 이으면 매번 새로 만든다
앞에서 문자열은 바꿀 수 없다고 했습니다. + 로 이으면 원래 것을 늘리는 것이 아니라 새 문자열을 만든다고요.
그때는 성질만 짚고 넘어갔습니다. 이제 그 성질이 얼마짜리인지 봅니다.
string s = "";
for (int i = 0; i < 10000; i++)
{
s += i;
}
이 코드는 문자열을 만 번 만듭니다. 하나가 아니라요.
못 바꾸니까 이을 때마다 새로 만들고, 만들 때마다 앞의 것을 전부 복사합니다.
한 바퀴 돌 때마다 무슨 일이
s += i 는 s = s + i 입니다. 세 단계입니다.
s의 길이 +i의 길이만큼 새 문자열 자리를 힙에 잡습니다- 옛
s를 통째로 복사해 넣습니다 i를 뒤에 붙이고, 그 새 문자열의 주소를s에 담습니다
옛 s 는 아무도 안 가리키게 되어 버려집니다.
문제는 2번입니다. 바퀴가 돌수록 복사할 양이 늘어납니다. 백 번째 바퀴에서는 앞의 백여 글자를 복사하고, 오천 번째에서는 오천여 글자를 복사합니다.
그래서 비용이 반복 횟수에 비례하는 것이 아니라 제곱에 비례합니다. 열 배 돌면 열 배가 아니라 백 배가 됩니다.
반복 횟수를 10 에서 100 으로 열 배 올려 보면 왼쪽 막대가 백 배 가까이 뜁니다.
StringBuilder 쪽은 딱 열 배만 오릅니다. 이 기울기 차이가 전부입니다.
StringBuilder 는 미리 크게 잡고 채운다
StringBuilder 는 문자열이 아니라 문자를 담는 배열을 들고 있습니다. 그리고 그 배열은 바꿀 수 있습니다.
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i);
}
string s = sb.ToString();
Append 는 배열 뒤에 이어 씁니다. 복사가 없습니다.
자리가 모자라면 그때 더 큰 배열로 옮기는데, 이때 두 배씩 늘립니다.
그래서 만 번 붙이는 동안 옮기는 일은 열몇 번밖에 안 일어납니다.
앞에서 List 가 칸이 모자라면 알아서 늘린다고 한 것과 같은 방식입니다.
List 의 그 성질을 문자에 적용한 것이 StringBuilder 라고 봐도 됩니다.
몇 자쯤 될지 알면 미리 알려 주면 옮기는 일까지 없앨 수 있습니다.
var sb = new StringBuilder(50000);
ToString() 을 부를 때 딱 한 번 진짜 문자열이 만들어집니다. 그 전까지는 문자열이 없습니다.
그런데 대부분은 신경 쓸 필요가 없다
여기가 더 중요합니다. + 를 보면 무조건 StringBuilder 로 바꾸는 것은 손해입니다.
한 식 안에서 잇는 것은 이미 최적화되어 있습니다.
string s = a + b + c + d;
이건 네 번 만드는 것이 아닙니다. 컴파일러가 string.Concat(a, b, c, d) 한 번으로 바꿉니다.
전체 길이를 먼저 재고 한 번에 만듭니다. StringBuilder 보다 오히려 빠릅니다.
보간 문자열도 마찬가지입니다.
string s = $"{이름}님 {점수}점";
요즘 컴파일러는 이걸 안에서 알아서 처리합니다. 손으로 StringBuilder 를 쓰는 것과 비슷하거나 낫습니다.
진짜 문제는 반복문 안에서 변수에 계속 누적할 때뿐입니다.
// 이것만 문제입니다
for (...) { s += 무언가; }
한 식 안이면 컴파일러가 전체 길이를 미리 알 수 있는데, 반복문은 몇 바퀴 돌지 모르니 도와줄 수가 없습니다.
기준
| 상황 | 무엇을 |
|---|---|
| 한 식에서 몇 개 잇기 | + 나 $"..." 그대로 |
| 반복문 안에서 누적 | StringBuilder |
| 구분자로 목록 잇기 | string.Join |
| 같은 조각을 여러 번 | string.Concat 이나 new string(c, n) |
string.Join 은 알아 둘 만합니다. 대부분의 “반복문으로 문자열 잇기” 가 이걸로 한 줄이 됩니다.
string s = string.Join(", ", 이름들);
안에서 StringBuilder 를 쓰고 있어서 직접 쓰는 것과 성능이 같습니다. 그리고 마지막 쉼표를 지우는 코드가 필요 없습니다.
Unity 라면
매 프레임 UI 텍스트를 갱신하는 자리가 이 이야기의 전형입니다.
void Update()
{
점수텍스트.text = "점수: " + 점수; // 프레임마다 문자열 하나
}
한 식이라 Concat 한 번이니 그 자체는 싸지만, 어쨌든 프레임마다 힙에 문자열이 하나 생깁니다.
초에 예순 개, 분에 삼천육백 개입니다. 앞에서 박싱을 볼 때와 같은 이야기입니다. 값이 아니라 횟수입니다.
가장 싼 해결은 문자열을 안 만드는 것입니다.
private int 이전점수 = -1;
void Update()
{
if (점수 != 이전점수)
{
점수텍스트.text = "점수: " + 점수;
이전점수 = 점수;
}
}
점수가 안 바뀌면 아무것도 안 만듭니다. StringBuilder 를 꺼내는 것보다 이쪽이 먼저입니다.
정리
- 문자열은 못 바꾸니
+=는 매번 새로 만들고 앞의 것을 전부 복사합니다 - 그래서 반복 횟수의 제곱에 비례해 비싸집니다. 열 배 돌면 백 배입니다
StringBuilder는 문자 배열을 들고 두 배씩 늘립니다. 복사가 거의 없습니다- 한 식 안의
+와$"..."는 이미 최적화되어 있습니다. 바꿀 필요가 없습니다 - 문제는 반복문 안에서 누적하는 것뿐입니다. 대개
string.Join으로 끝납니다 - 매 프레임 도는 자리라면 아예 안 만드는 것이 먼저입니다