개발을 하다 보면 정말 많이 사용하는 것이 바로 문자열입니다. 보통 문자열을 표현할 때에는 정말 순수하게 문자열만 표현할 수도 있지만, 간혹 숫자와 같이 작성하거나 다른 특수 기호와 함께 사용되어야 할 때에도 많이 존재하는데요.

C#에서는 조금 더 편리한 기능을 제공하는 보간 문자열에 대해서 알아보고자 합니다.

 

보간 문자열?

이름이 되게 어색하게 들릴 수 있지만 C# 6.0에 도입된 기능입니다.

제일 좋은 것은 바로 코드 가독성이 대폭 향상됩니다. 코드 가독성이 향상되었다는 것은 미연에 개발자의 실수를 줄이고 조금 더 복잡한 문제에 집중할 수 있게 된다는 뜻도 있습니다.

 

기존에 널리 사용되었던 string.Format() 또한 문자열 변환 과정을 잘 수행하지만, 생성된 문자열을 직접 출력해보고 올바른 형태인지를 눈으로 직접 확인하기 전까지는 코드를 제대로 작성했는지를 쉽게 판별하기가 어렵습니다.

 string format = "{0} money";
 Console.WriteLine(string.Format(format, 10));

포맷 문자열과 인자 리스트를 분리해서 전달하는 구조라 정확하게 포맷 문자열에 나타낸 인자의 개수와 실제로 전달되는 인자의 개수가 정확히 일치하는지 여부는 따로 확인해주지 않습니다. 자칫 실수로 이어져 필요한 인자를 누락하면 런타임에서 예외가 발생합니다.

 

저희 회사 프로젝트에서도 이러한 포맷팅 형식을 많이 사용하는데요.

간단한 포맷팅 형식이나 구조라면 상관없지만 {0} 와 같은 인자를 넘기는 횟수가 4개 이상 넘어가게 되면 이게 어떤 거고 그런지 파악하는 시간도 길어질 뿐더러 코드도 길어져 확인이 조금 더뎌지는 경우도 있습니다. ㅎㅎ(나쁘다는 것은 아닙니다.)

 

보간 문자열의 사용방법은 문자열 앞에 $를 붙이면 끝납니다. 그리고 문자열로 변경할 표현식은 { } 중괄호 내에 두면 됩니다.

Console.WriteLine($"{10} money");

엄청난 코드 가독성을 보여줍니다. 바로 문자열에 그 숫자가 넣어져 보여질 것을 인식할 만큼 빠르게 대입되는 것이 눈에 보이며, 결과 예측도 쉽습니다.

 

다만 주의할 점이 있습니다.

Console.WriteLine($"The value of pi is {Math.PI}");

문자열 보간 기능을 사용하더라도 실제 C# 컴파일러는 param을 이용해서 object 배열을 전달하는 기존 포맷팅 함수를 호출하도록 코드를 생성하게 됩니다.

Math.PI는 double 이므로 값 타입인데요. 이를 object 타입으로 변경하려면 박싱을 수행해야 합니다. 이러한 이유로 이 같은 코드를 너무 자주 사용한다거나 루프 내에서 사용하게 된다면 성능에 좋지 않은 영향을 미칠 수 있게 됩니다.

 

전달할 인자를 사전에 문자열로 변경한다면? 값 타입이 박싱되는 것을 회피할 수 있습니다.

Console.WriteLine($"The value of pi is {Math.PI.ToString()}"); // 3.141592653589793

기본적으로 제공되는 ToString() 메서드가 반환하는 문자열이 썩 유용하지 않은 경우가 있는데 이 경우 문자열을 어떻게 포맷팅 할 것인지를 표현하기 위해서 추가적으로 인자를 전달할 수 있습니다.

Console.WriteLine($"The value of pi is {Math.PI.ToString("F2")}"); // 3.14

 

보간 문자열의 표현식은 중첩해서 사용할 수 있습니다. { } 문자 사이의 모든 구문은 C# 코드의 일부인 동시에 표현식으로 간주됩니다.

Console.WriteLine($"The customer's name is {c?.Name ?? "Name is missing"}");

C가 NUll인지 Null 조건 연산자를 통해서 확인하고, 해당 Name 프로퍼티가 null 이면 오른쪽 구문을 수행하는 Null 병합 연산자까지 함께 사용한 코드가 위 코드입니다.

 

이처럼 문자열 보간 기능은 실수를 줄일 수 있고 더욱 강력할 뿐 아니라 활용도 또한 매우 높은 기술입니다.

적극 사용합시다!

 

최근 저도 개발하면서 문자열 관련 코드는 보간 문자열로 대체해서 사용하고 있습니다. ㅎㅎ

'CS > Effective C#' 카테고리의 다른 글

캐스트보다는 is, as가 좋다  (0) 2023.05.06
const보다는 readonly가 좋다.  (0) 2023.04.23
지역변수는 var를 사용하는 것이 낫다.  (0) 2023.04.23

캐스트의 경우에는 스타크래프트로 따지면, 드랍쉽에는 여러 유닛을 태울 수 있는데요. 여러 유닛의 각 특징들의 메서드(행동)를 가져오려고 할 때 그 유닛으로 캐스팅을 해야 그 유닛의 고유의 능력이나 행동을 가져올 수 있습니다.

그럴 때 캐스트가 필요하게 되는데요.

캐스트에 대해서 어떻게 하면 더 효율적으로 사용할 수 있는지에 대해서 알아봅시다.

 

C#은 정적 타이핑을 수행하는 언어입니다.

그래서 보통 판단은 정적으로 하려고 하는데요. 위와 같은 상황에서 런타임에서(게임 실행 중)에 반드시 타입을 확인해야할 필요가 있는 경우가 있습니다.

 

C#에서는 2가지 캐스트를 지원합니다.

1. as 연산자를 사용하는 방법 (더 방어적으로 사용하는 is 연산자로 형변환이 가능한지를 확인한 후에 실제 형변환 진행)

2. 일반적으로 알고 있는 컴파일러의 캐스트 연산자 구문을 사용하는 방법

 

형변환을 수행하는 경우에는 캐스팅을 사용하기보다는 as 연산자를 추천합니다.

- 안전한다.

- 런타임에 더 효율적으로 동작한다.

 

다만 as 나 is 연산자는 객체의 타입이 변환하려는 타입과 정확히 일치할 경우에만 형변환이 성공적으로 수행됩니다.

일반적으로는 형변환 과정에서 새로운 객체가 생성되는 경우는 없지만,

예외적으로 as 연산자를 이용하여 박싱된 값 타입의 객체를 nullable 값 타입의 객체로 변환하는 경우에 새로운 객체가 생성됩니다.

object o = Factory.GetValue();
var i = o as int?;
if (i != null)
    Console.WriteLine(i.Value);

위의 경우에는 as 연산자를 사용할 때 int는 값 타입이고 null이 될 수 없어서 일반적으로는 int로 형변환이 불가능하지만,

as 연산자는 그대로 사용하면서 nullable 타입으로 형변환을 수행한 후에 그 값이 null인지 판단으로 확인하는 방법도 있다.

 

as 연잔자를 사용해서 캐스팅 방법은 다음과 같다.

 object o = Factory.GetObject();

 MyType t = o as MyType;

 if (t != null)
 {
     // MyType 객체 사용
 }
 else
 {
     // 실패
 }
as 연산자와 캐스팅의 가장 큰 차이

사용자 정의 형변환을 어떻게 다루는가 하는 점입니다.

 

as나 is연산자는 런타임에 객체의 타입을 확인하고 필요에 따라서 박싱을 수행하는 것을 제외하고는 어떠한 작업도 수행하지 않습니다. 임의의 객체를 다른 타입으로 형변환하려면 이 객체는 지정한 타입이거나 혹은 지정한 타입을 상속한 타입이어야만 하고 그 외에는 null로 지정됩니다.

 

캐스팅의 경우 객체를 지정한 타입으로 변환하기 위해서 형변환 연산자가 개입될 수 있습니다.

대표적인 형변환 연산자가 바로 숫자 타입에 대한 형변환 연산자라면 (long 타입을 short 타입으로 캐스팅하면 일부 정보를 잃을 수 있다는 것)이 문제가 됩니다.

물론 사용자의 의도일 수 있겠지만 일반적으로 data의 손실이 되도록 코드를 짜는 것은 좋지 않습니다.

class SecondType
{
    private MyType value;

    public static implicit operator MyType(SecondType t)
    {
        return t.value;
    }
}

class MainApp
{
    static void Main()
    {
        object o = Factory.GetObject();

        // o는 SecondType이라고 가정하면
        // 첫 번째 방법
        MyType t = o as MyType;

        if (t != null)
        {
            // MyType 타입의 t 객체 사용
        }

        // 두 번째 방법
        try
        {
            MyType t2;
            t2 = (MyType)o;

            // MyType 타입의 t2 객체 사용
        }
        catch (InvalidCastException e)
        {
            // 형변환 오류 보고
        }
    }
}

as 연산자와 캐스팅의 방법은 모두 실패합니다.

캐스팅 방법을 사용하면 사용자 정의 형변환 연산자가 사용된다고 했었으나,,

컴파일러는 런타임에 객체가 어떤 타입일지를 예측하지 못..(정적 타이핑 이므로)

컴파일러는 단순히 컴파일 타임에 객체가 어떤 타입으로 선언됐는지만 추적하기 때문에 모두 실패하게 됩니다.

 

컴파일러는 객체 o가 런타임에 어떤 타입인지를 알 방법이 없습니다.

컴파일러는 객체 o가 object 타입이라고 생각하고 object 타입을 MyType으로 형변환할 수 있는 연산자가 정의됐는지만을 확인합니다. 이러한 형변환 연산자를 정의하지 않았기 때문에 컴파일러는 이제 o 객체가 MyType 형식인지를 확인하는 코드만 생성합니다.

런타임에 o는 SecondType 형식의 객체이므로 형변환에 실패하게 됩니다.

컴파일러는 런타임에 o가 어떤 타입일 지를 예측하고 이를 기반으로 MyType으로 형변환이 가능한지를 고려하지는 않습니다.

 

사용자 정의 형변환 연산자는 객체의 런타임 타입이 아닌 컴파일타임 타입에 맞춰 수행된다는 점에 다시 한번 유의합니다.

런타임에 o 객체를 MyType으로 변환할 수 있는지는 중요하지 않고 이에 대해서 알지도 못하며 고려하지도 않습니다.

다만 사용자 정의 형변환 연산자가 정의되었다면?

어떤 타입으로 선언되었느냐에 따라 다르게 동작할 수 있습니다.

 

캐스팅보다 as 연산자를 사용한다라면 어떤 타입으로 선언되었든 간에 항상 동일한 결과를 반환해줍니다.

t = o as MyType;

설사 사용자 정의 형변환 연산자가 정의됐다 하더라도 o가 MyType이나 MyType을 상속한 타입이 아니라면 컴파일 오류를 발생합니다.

 

foreach

자주 사용하는 foreach 루프는 제너릭 타입이 아닌 IEnumerable 인터페이스를 사용합니다.

class MainApp
{
    // version 1
    public void UseCollection(IEnumerable theCollection)
    {
        foreach (MyType t in theCollection)
            t.DoSuff();
    }
    
    // version 2
    public void UseCollection2(IEnumerable theCollection)
    {
        IEnumerator it = theCollection.GetEnumerator();
        while (it.MoveNext())
        {
            MyType t = (MyType)it.Current;
            t.DoSuff();
        }
    }
}

foreach 문은 캐스팅을 통해서 루프에서 사용되는 타입으로 객체를 형변환합니다.

version 1이 흔히 사용하는 foreach 문이고, 아래 version 2는 위의 foreach 문이 생성하는 코드입니다.

 

foreach 문은 값 타입과 참조 타입 모두에 대해서 형변환을 지원해야 하는데 이때 캐스팅을 사용하면 대상 타입을 구분할 필요가 없어지게 됩니다. 하지만 이로 인해 InvalidCastException이 발생할 가능성이 존재합니다.

 

foreach 문장은 컬렉션 내부의 객체가 런타임에 어떤 타입인지, 그 타입을 루프 변수 타입으로 형변환 가능한지 등을 확인하지 않습니다. IEnumerator.Current의 반환형인 System.Object 타입으로 형변환 가능한지와, Object 타입의 객체를 다시 루프 타입 변수로 형변환 가능한지만 확인합니다.

 

특별히 객체의 타입이 정확히 MyType인 경우에만 동작하는 함수를 만들고자 한다면 더 정밀한 타입 비교가 필요한데,

.NET 기본 클래스 라이브러리에는 시퀀스 내의 개별 요소들을 특정 타입으로 형변환하는 Enumerable.Cast<T>() 메서드가 존재합니다.

class MainApp
{
    static void Main(string[] args)
    {
        IEnumerable collection = new List<int>() { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

        var small = from int item in collection
                    where item < 5
                    select item;

        var small2 = collection.Cast<int>().Where(item => item < 5).Select(n => n);
    }
}

여기서 Cast<T> 메서드는 as 연산자 대신 캐스트 연산을 수행하는데, as 연산자를 사용하면 형변환하려는 타입에 제한이 생기기 때문에 Cast<T> 메서드를 구현하는 대신 캐스트 연산을 사용하는 단일 Cast<T> 메서드만 작성했다고 합니다.

 

가능하면 형변환은 피하자.

사용자의 의도를 명확히 표현할 수 있는 is나 as 연산자를 사용하는 것이 좋습니다.

is나 as 연산자는 거의 예상대로 동작하며 대상 객체를 올바르게 형변환할 수 있을 경우에만 성공하고 나머지는 실패합니다.

 

캐스트 연산보다는 is나 as를 사용하는 것이 의도하지 않은 부작용이나 예상치 못한 문제를 피할 수 있는 좋은 방법이 됩니다.

간혹 성능을 위해서 상수형을 작성하게 될 때 엄청난 고민을 하게 됩니다.

이때 누구는 const를 쓰고, 또 누구는 readonly를 쓰고 있습니다. 어떨 때 이걸 쓰고, 또 어떨 때 이걸 쓰는지 코드를 작성하다 보면 고민이 깊어져 2시간가량을 먹었던 기억이 있습니다.

그 답 오늘 해결해봅시다!

 

C#은 컴파일타임 상수와 런타임 상수 2 유형의 상수를 가집니다.

컴파일타임 상수는 const를 의미하고, 런타임 상수는 readonly를 의미합니다.

// 컴파일타임 상수
public cosnt int Millennium = 2000;

// 런타임 상수
public static readonly int ThisYear = 2004;

컴파일타임 상수가 약간 더 빠르긴 하지만, 런타임 상수에 비해서 유연성이 떨어집니다.

결론적으로 말하자면,

컴파일타임 상수는 성능이 매우 중요하고 상수의 값이 절대로 바뀌지 않는 경우에만 제한적으로 사용하고,

나머지는 런타임 상수를 사용하는 것을 추천합니다.

 

컴파일타임 상수는 메서드 내에서 선언이 가능, 런타임 상수는 메서드 내에서 선언이 불가능

서로 다르게 동작하는 이유는 값에 접근하는 방식이 서로 다릅니다.

컴파일타임 상수는 컴파일타임에 변수가 값으로 대체됩니다.

// 사용자가 작성한 코드
if (myDateTime.Year == Millenium)

// IL 코드로 컴파일되면 아래와 같다.
if (myDateTime.Year == 2000)

반면에 런타임 상수런타임에 값이 평가됩니다.

상수처럼 값으로 대체되지 않고 상수에 대한 참조로 컴파일됩니다. (한 번 살펴보고 그 값으로 컴파일된다는 뜻)

 

이러한 차이로 인해서

컴파일 상수는 내장된 숫자형, enum, 문자열, null에 대해서만 사용이 가능합니다.

오히려 그 외의 값을 사용하려고 할 때에는 컴파일 오류가 발생합니다.

private const DateTime localTime = new DateTime(2023, 4, 23, 0, 0, 0);

런타임 상수는 생성자에서 초기화될 수 있으며 그 이후에는 수정될 수 없습니다.

런타임은 컴파일 상수보다 아까 유연하게 사용될 수 있다고 했습니다.

해당 런타임 상수는 어떤 타입과도 함께 사용이 가능하며, 멤버 초기화 구문뿐 아니라 생성자를 통해서도 초기화가 가능하다는 점입니다. (서로 같은 클래스의 인스턴스지만 내부적으로는 생성자에 의해 서로 다른 값을 가지는 장점도)

 

클래스 내에서 런타임 상수를 정의하는 경우라면 동일 클래스의 인스턴스라 하더라도 인스턴스 별로 서로 다른 값을 가질 수 있습니다. 반면에 컴파일타임 상수는 정의에 따라 정적 상수이므로 모든 인스턴스가 동일한 값을 가질 수밖에 없습니다.

 

가장 중요한 차이

상수의 값이 런타임에 평가된다는 점입니다.

런타임 상수를 참조하는 코드를 컴파일하면 컴파일타임 상수처럼 코드를 값으로 대체하지 않고, readonly 변수에 대한 참조 코드를 생성합니다.

 

응용프로그램을 유지보수할 때 상당한 영향을 미치는데요.

컴파일타임 상수는 다른 어셈블리의 참조 여부와 상관없이 항상 숫자나 문자열 등을 직접 사용한 것과 완전히 동일한 IL 코드를 생성합니다.

물론 성능상 좋겠지만 응용프로그램 전체를 리빌드(rebuild) 하지 않고 수정된 부분만 빌드(build)하면 오히려 예상했던 것과 다르게 이전에 빌드했는 결과가 나올 수 있다는 것입니다.

 

응용프로그램의 크기가 작으면 상관없겠지만, 보통은 리빌드 하는데 30 ~ 1시간 (많은 시간)이 소요되기 때문에 내가 수정한 영역만 다시 빌드하는 Build를 하기 마련입니다. 그런데 빌드만 하기에는 컴파일타임 상수는 수정된 것으로 반영되지 않는다는 것이죠. 그래서 해당 상수를 참조하는 모든 코드를 반드시 재컴파일 해야 합니다.

 

반대로 런타임 상수는 이러한 대응에 유연합니다. readonly 변수에 대한 참조 코드를 생성했기 때문에 컴파일 시에 참조 코드가 생성되고 런타임이 비로소 그 값을 평가하게 됩니다.

이렇게만 보면 readonly가 최고?!

readonly 대신 const를 사용했을 때 얻을 수 있는 장점은 성능이 빠릅니다.

코드를 상수값으로 대체하기 때문에 readonly 변수를 통해서 값을 참조하는 것보다는 당연히 빠를 수밖에 없습니다.

하지만,, 성능 개선 효과가 크지 않고 유연성을 해치기 때문에 사용자의 판단이 무엇보다 중요합니다.

 

특성 매개변수(attribute), swich/case 문의 레이블, enum 정의 시 사용하는 상수 등은 컴파일 시에 사용돼야 하므로 반드시 const 통해서 초기화하고 그 외에는 readonly를 사용하는 것이 좋습니다.

최근에 인턴으로 들어오신 팀원이 지난 주에 직군 면접을 봤었습니다.

직군 면접에 대해서 이것저것 얘기하다 보니 생각난 것이 있습니다.

바로 Effective C#인데요.

사수님께서 입사 후 제일 먼저 사주신 책입니다.

예전에 직군 면접 보기 전에 한번 정독했던 기억도 나고, 블로그에 정리하면서 내실을 다지는 것이 어떨까하여 시작합니다.

 

가장 흥미로우면서도 편리한 var 에 대해서 설명하는 단원이네요.

 

득 보다 실이 많다.

정확한 반환 타입을 알지 못한 채 올바르지 않은 타입을 명시적으로 지정하게 되면 오히려 손해라는 뜻입니다.

극단적으로 값 타입을 object 형으로 받아버리는 것도 마찬가지죠.

 

버그가 발생하여 유지보수를 하려고 할 때에 많은 코드 분석을 하게 됩니다.

이럴 때 너무 긴 코드는 오히려 집중도를 떨어트리기도 합니다. 이때는 var를 사용해서 너무 긴 타입의 형은 간단한 var 형식으로 처리할 수 있습니다.

// 1
Dictionary<int, MainQuest> mainQuestDic = new Dictionary<int, MainQuest>();
// 2
var mainQuestDic = new Dictionary<int, MainQuest>();

 주렁 주렁 타입이 긴 변수명은 생각을 해야 하는 프로그래머가 작성에만 집중하게 된다는 단점이 있습니다.

 

C#에서는 특정 변수를 var로 선언하게 되면, 동적으로 수행되는 것이 아니라 할당 연산자 오른쪽의 타입을 확인하고

왼쪽 변수의 타입을 결정하게 됩니다.

컴파일러는 변수의 타입을 명시적으로 알려주지 않더라도 개발자를 대신해서 올바른 타입으로 추론합니다.

마치 CPP의 auto 랑 비슷하네요.

 

Primitive type 은 var를 함께 사용할 때 주의가 필요합니다.

내장된 숫자 타입을 var랑 함께 사용하는 것은 문제가 되진 않으나 예상했던 결과와 다르게 동작할 수 있습니다.

var total = 100 * GetMagicNumber() / 6;

// GetMagicNumber() 가 어떤 타입의 값을 반환하는 지에 따라 데이터가 잘릴 수도 있다.

개발자는 컴파일러 처럼 내부적으로 이뤄지는 타입 추론과 암시적 변환 과정을 쉽사리 명확하게 이해하기가 어렵습니다. 간혹 휴먼 에러가 발생합니다.

명확한 계산(= 의도한 계산)에 대해서는 명시적으로 선언하는 것이 좋습니다.

 

오잉 그러면 결론?

var를 이럴 때 사용해보자.

- 모호함을 불러일으킬 가능성이 있는 타입은 명시적으로 선언하는 것이 좋다.

- Primitive type에 대해서는 가능하면 명시적으로 선언하여 실수를 줄이자.

그 외에는 var를 사용하는 것이 베스트다.

'CS > Effective C#' 카테고리의 다른 글

string.Format()을 보간 문자열로 대체하라  (0) 2023.05.06
캐스트보다는 is, as가 좋다  (0) 2023.05.06
const보다는 readonly가 좋다.  (0) 2023.04.23

+ Recent posts