(C#) 24. 문자열 살펴보기
찾고 바꾸고 자르는 기본 함수들. string이 불변이라 Replace가 원본을 안 바꾼다는 것, 루프에서 이어붙이면 왜 느려지는지, ToLower가 터키어에서 다르게 동작하는 것까지 정리했다.
문자열은 참조 타입인데 값처럼 동작한다
2편에서 참조 타입의 ==는 같은 객체인지를 본다고 적으면서 string은 예외라고 했다. 그 이유가 이 편의 출발점이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
using System;
namespace String
{
class Program
{
static void Main(string[] args)
{
string name = "Harry Potter";
// 1. 찾기
bool found = name.Contains("Harry");
int index = name.IndexOf('P'); // 6
//int index = name.IndexOf('z'); // -1
// 2. 변형
name = name + " Junior";
string lowerCaseName = name.ToLower();
string upperCaseName = name.ToUpper();
string newName = name.Replace('r', 'l');
// 3. 분할
string[] names = name.Split(new char[] { ' ' });
string substringName = name.Substring(5);
}
}
}
변형 함수는 원본을 안 바꾼다
이게 제일 중요한 성질이다. string은 한 번 만들어지면 절대 바뀌지 않는다.
1
2
name.ToLower();
Console.WriteLine(name); // "Harry Potter Junior" 그대로
ToLower, ToUpper, Replace, Trim, Substring 전부 새 문자열을 만들어 돌려준다. 원본은 그대로다.
원 코드가 결과를 전부 변수에 받고 있어서 맞게 쓰고 있다.
1
2
string lowerCaseName = name.ToLower(); // 받아야 한다
string newName = name.Replace('r', 'l');
받지 않고 그냥 부르면 아무 일도 안 일어난다. 컴파일도 되고 경고도 없다. “분명 소문자로 바꿨는데 왜 그대로지”에서 헤매는 경우가 여기서 나온다.
1
name.Replace('r', 'l'); // 결과를 버린다
name = name.Replace('r', 'l');처럼 다시 대입해야 한다.
그래서 == 가 값 비교인 것
불변이라 값이 같으면 객체를 공유해도 안전하다. .NET은 소스에 있는 문자열 리터럴을 한 군데 모아두고 재사용한다.
1
2
3
string a = "hello";
string b = "hello";
Console.WriteLine(object.ReferenceEquals(a, b)); // True. 같은 객체다
여기에 더해 string은 ==를 내용 비교로 오버로드해뒀다. 그래서 실행 중에 만들어진 문자열도 내용이 같으면 ==가 참이다.
루프에서 이어붙이면 느려진다
1
name = name + " Junior";
한 번은 괜찮다. 문제는 루프 안에서 할 때다.
1
2
3
string s = "";
for (int i = 0; i < 10000; i++)
s += i.ToString(); // 매번 새 문자열을 만든다
+=는 기존 문자열을 바꾸는 게 아니라 새 문자열을 만들어 대입한다. 길이가 늘어날수록 복사할 양도 늘어나서 전체 비용이 길이의 제곱에 비례한다. 버려진 문자열이 계속 쌓이니 GC도 바빠진다.
StringBuilder는 안에 버퍼를 두고 거기에 이어 붙인다.
1
2
3
4
5
6
using System.Text;
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
sb.Append(i);
string s = sb.ToString();
13편에서 출력을 모아 한 번에 내보낸 게 이 이유였다.
몇 개 안 되는 고정된 조합이면 그냥 +나 문자열 보간이 낫다. 컴파일러가 string.Concat 한 번으로 합쳐준다. 기준은 “루프 안이냐 아니냐”였다.
찾기
1
2
3
bool found = name.Contains("Harry");
int index = name.IndexOf('P'); // 6
//int index = name.IndexOf('z'); // -1
IndexOf는 못 찾으면 −1을 준다. 0이 아니다. 0은 맨 앞에서 찾았다는 뜻이라 유효한 값이다.
1
2
if (name.IndexOf('H') > 0) // 맨 앞에 있으면 0 이라 걸러진다
if (name.IndexOf('H') >= 0) // 이게 맞다
>= 0을 > 0으로 잘못 쓰면 첫 글자만 못 찾는 버그가 된다. 존재 여부만 필요하면 Contains가 헷갈릴 여지가 없다.
자르기
1
string[] names = name.Split(new char[] { ' ' });
Split은 params 매개변수라 배열을 만들 필요가 없다.
1
2
string[] names = name.Split(' ');
string[] parts = name.Split(' ', ','); // 여러 구분자
공백이 연속으로 있으면 빈 문자열이 끼어든다. 그걸 빼려면 옵션을 준다.
1
name.Split(' ', StringSplitOptions.RemoveEmptyEntries);
사용자 입력을 자를 때 이게 필요했다. 실수로 스페이스를 두 번 친 입력에서 빈 항목이 생겨 개수가 안 맞았다.
1
string substringName = name.Substring(5);
Substring은 인덱스가 범위를 벗어나면 ArgumentOutOfRangeException이다. 길이를 확인하고 부르거나, C# 8부터는 범위 연산자를 쓸 수 있다.
1
2
3
string s1 = name[5..]; // 5 번부터 끝까지
string s2 = name[..5]; // 처음부터 4 번까지
string s3 = name[^6..]; // 뒤에서 6 글자
^가 끝에서부터 세는 표기다. 파일 확장자를 떼거나 붙일 때 편했다.
ToLower 가 언어에 따라 다르게 동작한다
이건 나중에 알고 놀랐던 부분이다.
1
"I".ToLower()
대부분의 환경에서는 "i"가 나온다. 그런데 터키어 문화권 설정에서는 점 없는 "ı"가 나온다. 터키어에서는 대문자 I의 소문자가 점 없는 글자이기 때문이다.
명령어나 확장자를 소문자로 바꿔 비교하는 코드가 터키 사용자 PC에서만 깨지는 사고가 여기서 나온다.
비교가 목적이면 문화권 영향을 안 받는 쪽을 쓴다.
1
name.ToLowerInvariant();
애초에 대소문자 무시 비교를 쓰는 게 더 낫다. 새 문자열을 안 만드니 빠르기도 하다.
1
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) { }
7편에서 "Y"를 입력하면 안 끝나던 문제에 이걸 썼다.
null 과 빈 문자열
1
2
string s = null;
Console.WriteLine(s.Length); // NullReferenceException
null인 string에 함수를 부르면 죽는다. 빈 문자열("")과는 다르다.
1
2
if (string.IsNullOrEmpty(s)) { } // null 이거나 ""
if (string.IsNullOrWhiteSpace(s)) { } // 위에 더해 공백만 있는 경우도
사용자 입력에는 IsNullOrWhiteSpace가 맞았다. 스페이스만 친 입력을 “입력했다”로 치면 안 되니까.
정리하면
string은 불변이다.Replace,ToLower같은 함수는 새 문자열을 돌려주고 원본은 안 바꾼다- 결과를 안 받으면 아무 일도 안 일어나고 경고도 없다
- 불변이라 리터럴을 공유할 수 있고,
==가 내용 비교로 오버로드되어 있다 - 루프에서
+=는 매번 새 문자열을 만든다.StringBuilder를 쓴다 IndexOf는 못 찾으면 −1이다.> 0이 아니라>= 0으로 검사한다Split은 구분자를 그냥 나열하면 되고, 빈 항목은RemoveEmptyEntries로 뺀다ToLower는 문화권에 따라 결과가 다르다. 비교 목적이면StringComparison.OrdinalIgnoreCasenull과""는 다르다.IsNullOrWhiteSpace로 한 번에 거른다