문자열은 바꿀 수 없다
앞 글에서 글자 하나에 번호가 붙는 걸 봤습니다. 'A' 는 65였습니다.
이제 글자 여러 개를 다룹니다. 그게 string 입니다.
string s = "Hello";
Console.WriteLine(s.Length); // 5
Console.WriteLine(s[0]); // H
Console.WriteLine(s[4]); // o
Console.WriteLine(s[5]); // IndexOutOfRangeException
글자가 다섯 개인데 s[5] 가 없습니다. 그리고 첫 글자가 s[1] 이 아니라 s[0] 입니다.
이게 컴퓨터의 별난 습관처럼 보이는데, 앞의 이야기로 돌아가면 당연한 결과입니다.
문자열은 문자를 나란히 놓은 것
"Hello" 는 char 다섯 개입니다. 그리고 그 다섯 개가 메모리에 빈틈없이 붙어 있습니다.
앞 글에서 본 대로 char 는 2바이트니, 이 문자열은 10바이트를 통째로 씁니다.
붙어 있으면 좋은 점이 하나 있습니다. 몇 번째 글자든 계산으로 바로 찾습니다. 시작 지점을 알고 칸 하나의 크기를 알면 나머지는 곱셈과 덧셈입니다.
찾아갈 자리 = 시작 지점 + 인덱스 × 칸 하나의 크기
글자를 눌러 보세요.
여기서 답이 나옵니다. 인덱스 0을 넣으면 시작 지점 그대로입니다.
0x1000 + 0 × 2 = 0x1000
첫 글자는 시작 지점에서 0칸 떨어져 있습니다. 그래서 0번입니다. 1번이 아닙니다.
인덱스는 “몇 번째냐” 가 아니라 “시작에서 몇 칸 떨어졌느냐” 입니다.
달리기 출발선과 같습니다. 출발선에 서 있는 첫 사람은 0미터 지점에 있습니다. 1미터 지점이 아닙니다.
마지막은 Length 가 아니라 Length - 1
글자가 5개면 인덱스는 0 부터 시작하니 마지막은 4 입니다.
s[5] 는 다섯 칸 떨어진 자리인데, 거기는 문자열이 끝난 뒤입니다.
남의 자리라서 예외를 던집니다.
for (int i = 0; i < s.Length; i++) // i <= s.Length 가 아닙니다
{
Console.WriteLine(s[i]);
}
<= 로 쓰면 마지막 한 바퀴에서 예외가 납니다.
반복문에서 < 를 쓰는 이유가 이것입니다.
마지막 글자를 꺼낼 때는
s[s.Length - 1]대신s[^1]을 쓸 수 있습니다.^는 “끝에서부터” 라는 뜻이고^1이 마지막 글자입니다. 끝에서 세는 것이라^0은 없습니다.
전부 훑기만 할 거면 foreach 가 낫습니다. 인덱스를 안 쓰니 한 칸 넘어갈 일도 없습니다.
foreach (char c in s)
{
Console.WriteLine(c);
}
읽을 수는 있는데 쓸 수는 없다
인덱스로 글자를 읽는 것은 됩니다. 그런데 쓰는 것은 안 됩니다.
string s = "Hello";
char c = s[0]; // 읽는 건 됩니다
s[0] = 'h'; // 컴파일 에러
C# 의 string 은 불변(immutable) 입니다.
한 번 만들어지면 그 내용이 끝까지 바뀌지 않습니다.
왜 이렇게 만들었을까요. 문자열은 여기저기서 돌려쓰기 때문입니다. 누군가 내용을 고칠 수 있으면 그 문자열을 들고 있던 다른 코드도 같이 바뀝니다. 바뀌지 않는다고 보장하면 그런 사고가 없고, 여러 곳에서 같이 읽어도 안전합니다.
그래서 문자열을 바꾸는 메서드들은 원본을 고치는 게 아니라 바뀐 새 문자열을 돌려줍니다.
string s = "Hello";
s.ToUpper(); // 아무 일도 안 일어납니다
Console.WriteLine(s); // Hello
s = s.ToUpper(); // 돌려받은 것을 다시 담아야 합니다
Console.WriteLine(s); // HELLO
ToUpper · ToLower · Replace · Trim · Substring 전부 같습니다.
돌려받지 않으면 아무 일도 일어나지 않습니다. 처음에 자주 걸리는 자리입니다.
+ 는 새 문자열을 만든다
불변이라는 성질은 + 에서 비용이 됩니다.
string s = "Hello";
s = s + " World";
기존 문자열이 늘어나는 게 아닙니다. "Hello World" 라는 새 문자열이 따로 만들어지고,
s 가 그쪽을 가리키게 됩니다. 원래 "Hello" 는 아무도 안 쓰게 되어 GC 를 기다립니다.
쓰던 종이에 이어 쓰는 게 아니라 매번 새 종이에 전부 옮겨 적는 것과 같습니다.
한두 번이면 아무 문제 없습니다. 반복문 안에 들어가면 달라집니다.
열 번은 차이가 없고, 만 번은 다른 세상입니다.
+ 는 매번 지금까지 쌓인 것을 전부 새 종이에 옮겨 적기 때문입니다.
그래서 반복문 안에서 문자열을 쌓을 때는 StringBuilder 를 씁니다.
StringBuilder 는 바꿀 수 있는 버퍼입니다. 다 쌓은 뒤 ToString() 으로
한 번만 문자열을 만듭니다. using System.Text; 가 필요합니다.
그래도 몇 번은 그냥 + 를 써도 된다
한 줄 안에서 여러 번 이어붙이는 것은 괜찮습니다.
string name = "손" + "오공" + " 님";
컴파일러가 이런 연속된 + 를 한 번의 합치기로 묶어줍니다.
중간 문자열이 생기지 않습니다.
문제가 되는 건 반복문처럼 한 번에 한 조각씩 여러 번 나눠 붙일 때입니다. 그때는 컴파일러가 묶을 수가 없습니다.
읽기 좋게 쓸 거면 문자열 보간이 낫습니다.
string msg = $"{name} 님의 점수는 {score}점입니다";
앞에 $ 를 붙이면 중괄호 안에 변수를 그대로 넣을 수 있습니다.
+ 로 이어붙이는 것보다 읽기 쉽고 속도도 같습니다.
Length 가 눈에 보이는 글자 수가 아닐 수 있다
앞 글에서 char 가 UTF-16 한 조각이라 2바이트라고 했습니다.
여기에 한 가지가 딸려 옵니다.
Console.WriteLine("한글".Length); // 2 (글자 두 개, 맞습니다)
Console.WriteLine("👍".Length); // 2 (글자는 하나인데요?)
char 는 글자 하나가 아니라 UTF-16 한 조각입니다.
대부분의 글자는 조각 하나에 들어가서 한글도 한자도 문제없습니다.
그런데 이모지 같은 일부 문자는 조각 두 개를 써야 합니다.
그래서 이모지가 섞인 문자열은 Length 가 눈에 보이는 글자 수와 다르고,
인덱스로 자르면 글자가 반토막 납니다.
눈에 보이는 글자 단위로 세거나 잘라야 한다면
System.Globalization.StringInfo를 쓰거나, 최신 C# 이라면EnumerateRunes()를 씁니다. 사용자 닉네임 길이 제한처럼 사람이 세는 글자 수가 필요한 자리에서 이 차이가 드러납니다.
정리
- 문자열은 문자를 메모리에 나란히 놓은 것이다.
- 인덱스는 순번이 아니라 시작 지점에서의 거리다. 그래서 0부터 시작한다.
- 마지막 인덱스는
Length가 아니라Length - 1이다. 반복문에서<를 쓰는 이유다. string은 불변이다. 읽을 수는 있어도 쓸 수는 없다.ToUpper같은 메서드는 원본을 고치지 않고 새 문자열을 돌려준다. 돌려받아야 한다.+는 매번 새 문자열을 만든다. 반복문 안에서는StringBuilder를 쓴다.- 한 줄 안의 연속된
+는 컴파일러가 묶어주므로 괜찮다. char는 UTF-16 한 조각이라, 이모지가 섞이면Length가 보이는 글자 수와 다르다.