포스트

(C#) 24. 문자열 살펴보기

찾고 바꾸고 자르는 기본 함수들. string이 불변이라 Replace가 원본을 안 바꾼다는 것, 루프에서 이어붙이면 왜 느려지는지, ToLower가 터키어에서 다르게 동작하는 것까지 정리했다.

(C#) 24. 문자열 살펴보기

문자열은 참조 타입인데 값처럼 동작한다

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[] { ' ' });

Splitparams 매개변수라 배열을 만들 필요가 없다.

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

nullstring에 함수를 부르면 죽는다. 빈 문자열("")과는 다르다.

1
2
if (string.IsNullOrEmpty(s)) { }        // null 이거나 ""
if (string.IsNullOrWhiteSpace(s)) { }   // 위에 더해 공백만 있는 경우도

사용자 입력에는 IsNullOrWhiteSpace가 맞았다. 스페이스만 친 입력을 “입력했다”로 치면 안 되니까.

정리하면

  • string은 불변이다. Replace, ToLower 같은 함수는 새 문자열을 돌려주고 원본은 안 바꾼다
  • 결과를 안 받으면 아무 일도 안 일어나고 경고도 없다
  • 불변이라 리터럴을 공유할 수 있고, ==가 내용 비교로 오버로드되어 있다
  • 루프에서 +=는 매번 새 문자열을 만든다. StringBuilder를 쓴다
  • IndexOf는 못 찾으면 −1이다. > 0이 아니라 >= 0으로 검사한다
  • Split은 구분자를 그냥 나열하면 되고, 빈 항목은 RemoveEmptyEntries로 뺀다
  • ToLower는 문화권에 따라 결과가 다르다. 비교 목적이면 StringComparison.OrdinalIgnoreCase
  • null""는 다르다. IsNullOrWhiteSpace로 한 번에 거른다
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.