• [.NET / C#] String.Create<TState>()

    2022. 1. 23.

    by. xxx123

     

     

    String.Create()의 효용성

     

     

    여러 문자열을 합쳐서 새로운 문자열을 만든다고 가정해보자.

    string str1 = "a";
    string str2 = "b";
    
    string newStr = "  "; // str1과 str2를 합치기 위해 길이가 2인 빈 string 생성
    newStr[0] = 'a';       // 불변이기에 수정이 불가능.
    newStr[1] = 'b';       // 불변이기에 수정이 불가능.

     

    String은 사실상 불변 타입이기에 원하는 사이즈의 빈 String을 생성한 후에 그 String에 값을 채워 넣는 행위는 허락되지 않는다. 

    그렇기에 여러 문자열을 합쳐서 새로운 문자열을 만들 때 .NET에서 제공하는 String.Concat()과 같은 'String 전용 특수 함수'를 사용하지 않는 한 그 과정이 꽤나 비효율적으로 진행되게 된다.

     

    우선 String.Concat()을 사용하지 않고 두 String을 합치는 코드를 작성해 보면 다음과 같을 것이다.

                public class StringData
                {
                    string str1 = "Hello";
                	string str2 = "World";
                }
                
                StringData strData = new StringData();
                int length = strData.str1.Length + strData.str2.Length;
                int pos = 0;
                var buffer = new char[length];    // 1. 두 string을 합칠 char 배열을 할당.
    
                for (var i = 0; i < strData.str1.Length; i++)
                {
                    buffer[pos++] = strData.str1[i];
                }
    
                for (var i = 0; i < strData.str2.Length; i++)
                {
                    buffer[pos++] = strData.str2[i];
                }
    
    
                string newStr = new string(buffer); // 2. string을 할당. 
                                                    // 3. char 배열을 새로운 string으로 복사.

     

     

    이를 도식화 해보면 다음과 같다.

     

    두 String을 합치기 위한 임시 Char 배열의 할당으로 인해 가비지가 발생하게 되고

    또 그 배열에 두 String을 복사하여 합친 내용을 또 다시 새로운 String으로 복사하는 굉장히 비효율적인 과정을 거친다.

    실제로 StringBuilder가 String을 합치기 위해 위와 유사한 과정을 수행한다. (물론 StringBuilder의 목적은 한번의 함수콜로 모든 string을 합치는 것이 목적은 아니기에 단순히 비효율적이라고 보기는 어렵다.)

     

     

    String.Create()는 특정 사이즈 만큼의 String을 할당함과 동시에 원하는 초기화를 할 수 있도록 해준다.

    위의 비효율적인 과정이 String.Create()을 사용하면 어떻게 변경 되는지 보도록 하자.

                public class StringData
                {
                    string str1 = "Hello";
                	string str2 = "World";
                }
                
                StringData strData = new StringData();
                
                int length = strData.str1.Length + strData.str2.Length;
                // 1. String 할당과 함께 str1, str2 복사
                var result = String.Create(length, strData, (buffer, strData) =>
                {
                    int pos = 0;
                    for (int i=0; i< strData.str1.Length ; ++i)
                    {
                        buffer[pos++] = strData.str1[i];
                    }
    
                    for (int i = 0; i < strData.str2.Length; ++i)
                    {
                        buffer[pos++] = strData.str2[i];
                    }
                });

     

    불필요하게 Char배열을 만들어 할당하고 다시 String으로 복사하는 비효율적인 작업이 사라진 것을 볼 수 있다.

     

    그렇다면 위에서 잠깐 'String 관련 특수함수'라고 언급한 String.Concat()을 쓰면 첫 예제와 같이 비효율적인 String 생성 과정을 거치지 않아도 되는 것일까?

    사실은 그렇다.

    String.Concat()의 경우에도 FastAllocateString()이라는 함수를 내부적으로 사용하여 String의 사이즈를 잡고, unsafe를 통해 포인터 연산으로 String을 복사하는 방식을 취하고 있다. (FastAllocateString()의 경우 internal 함수이기에 우리는 사용할 수 없다.)

    최종적으로는 String.Create()를 사용하는 것과 크게 다르지 않은 과정이 이루어 지는 것이다.

     

    string str1 = "Hello";
    string str2 = "World";
    string newStr = str1 + str2;

    게다가 위 처럼 작성한 코드도 최종적으로는 String.Concat()을 사용하는 코드로 변경되기에 생각보다 String.Create()를 쓸일이 딱히 없어 보인다.

     

    하지만 아쉽게도 String.Concat()이 모든 상황에서 효율적인 동작을 하는 것만은 아니다.

            // string.cs
            public static String Concat(params String[] values) 
            {
    ...
                int totalLength=0;
                
                // 다른 스레드에 의해 string 배열이 변경될 것에 대비해 복사.
                // 하지만 관리 메모리 할당.
                String[] internalValues = new String[values.Length];
                
                for (int i=0; i<values.Length; i++) {
                    string value = values[i];
                    internalValues[i] = ((value==null)?(String.Empty):(value));
                    totalLength += internalValues[i].Length;
                    // check for overflow
                    if (totalLength < 0) {
                        throw new OutOfMemoryException();
                    }
                }
                
                return ConcatArray(internalValues, totalLength); //
            }

    위와 같이 String.Concat()의 인자가 4개를 넘어가는 순간부터는 params String[]을 인자로 사용하는 함수를 호출하게 되며 이 함수의 경우 인자로 넘겨진 String의 배열이 다른 스레드에 의해 변경될 것을 우려해 배열을 다른 배열로 복사하기에 추가적으로 String 배열의 할당이 발생한다.

    그러므로 합치려는 String의 수가 5개 이상인 상황에서 String.Create()을 사용하면 어느 정도의 성능적 효과를 볼 수 있을 것이다.

     

    String.Concat()처럼 .NET에서 어느정도 최적화된 기능을 제공하는 경우라면 그 기능을 사용해도 큰 불이익은 없을 것이다. 

    하지만 String을 조금 다른 형태로 커스텀하게 변환하여 새로운 String을 만들고 싶다던지 (예를 들어 String에 알파벳 'a'가 들어가면 'a'를 'b'로 바꿔서 새로운 String을 만든다던지)

    정수 타입을 통해 String 키값을 뽑아내고 싶은 경우라면 String.Create()로 퍼포먼스 향상을 도모해 볼 수 있을 것이다.

     

     

     

     

    TState 파라미터

     

    이번엔 String.Create()의 파라미터를 보도록 하자.


    public static string Create<TState> (int length, TState state, System.Buffers.SpanAction<char,TState> action);

     

    첫 번째 인자는 String의 길이.

    두 번째 인자인 state는 세 번째 인자인 action에서 사용될 변수라는 것을 알 수 있다.

     

    여기서 두 번째 인자인 state의 경우 굳이 인자로 따로 넘기지 않아도 action 함수 내에서 캡쳐하여 사용할 수 있는 불필요한 변수처럼 느껴진다.

    실제로 그럼에도 불구하고 별도의 인자로 만들어 넘기는 이유는 action의 변수 캡쳐로 인해 생성되는 Closure 클래스 인스턴스를 없애기 위함이다.

    생성된 Closure 인스턴스는 참조타입인 만큼 관리힙에 생성되기 때문에 최적화를 지향하기 위해 만들어진 String.Create()에서는 이를 사용하지 않는 방향을 선택한 것이다.

     

     

     

     

     

     

     

     

     

     

     

     


    참고

    https://docs.microsoft.com/ko-kr/dotnet/api/system.string.create?view=net-6.0
    https://www.stevejgordon.co.uk/creating-strings-with-no-allocation-overhead-using-string-create-csharp


     

    댓글