[Effective C#] Ch1. C# 언어 요소

2022. 4. 10. 16:37카테고리 없음

✅ 지역변수를 선언할 때는 var를 사용하는 것이 낫다.

- C# 언어가 익명 타입을 지원하기 위해서 타입을 암시적으로 선언할 수 있는 손쉬운 방법을 제공한다.

- 정확한 반환 타입을 알지 못한 채 올바르지 않은 타입을 명시적으로 지정하게 되면 득보다 실

ex) 일부 쿼리 구문의 경우 IEnumerable<T>컬렉션이 아니라 IQueryable<T>를 반환하기도 함

만약 여기서 강제 형변환을 하면 IQueryProvider가 제공하는 장점을 모두 잃게 된다.

 

- var로 선언하면 더 잘 읽힌다.

변수 타입 같은 변수의 지엽적인 부분보다는 변수의 의미 파악에 집중하게 한다.

 

하지만 간혹 사용자가 var를 사용하여 컴파일러에게 타입 추론을 위임한 경우,

컴파일러는 할당문 오른쪽의 내용을 기반으로 타입을 결정하기 때문에 버그 발생 가능성이 있다.

 

그래도 때로는 변수의 타입을 명시적으로 선언하는 것보다 컴파일러에게 타입을 추론하도록 맡기는 편이 더 낫다.

그리고 변수의 이름을 통해서 그 역할을 명확하게 드러내도록 코드를 작성하는 것이 훨씬 낫다.

 

요약

코드를 읽을 때 타입을 명시적으로 드러내야 하는 경우가 아니라면 var를 사용하는 것이 좋다.

다만 내장 숫자 타입을 선언할 때는 명시적으로 타입을 선언하는 편이 낫다.

✅ const 보다는 readonly가 좋다.

C#의 상수

- 컴파일타임 상수

- 런타임 상수

 

컴파일타임 상수보다 런타임 상수를 이용해라

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

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

 

// 컴파일타임 상수
public const in Millennum = 2000;

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

컴파일 타임 상수

- 메서드 내부에서 선언 가능

- 컴파일타임에 변수가 값으로 대체된다.

- 따라서 내장 자료형(숫자형, enum 문자열, null)만 가능(DateTime 같은 것은 불가능)

- 해당 상수를 참조하는 모드 코드를 반드시 재컴파일해야 한다.

- 대신 빠르다.

 

런타임 상수

- 메서드 내부에서 선언 불가능

- 런타임에 값이 평가된다. -> 컴파일타임 상수처럼 코드를 값으로 대체하지 않고, readonly 변수에 대한 참조 코드 생성

- 내장 자료형이 아닌 것도 가능하기 때문에 유연

 

예외적인 상황

- 컴파일할 때 사용되는 상숫값을 정의할 때는 반드시 const를 사용해야 한다.

- attribute의 매개변수, switch/case 문의 레이블, enum 정의 시 사용하는 상수들은 컴파일 시에 사용돼야하므로 반드시 const를 통해 초기화해야한다.

 

 

✅ 캐스트보다는 is, as가 좋다.

C#에서 형변환을 수행하는 방법

- as 연산자

- 컴파일러의 캐스트 연산자 구문

 

더 방어적인 코드를 위해서는

- is 연산자로 형변환이 가능한지를 확인 후

- 실제 형변환을 수행하도록 코드 작성

 

캐스팅보다는 as 연산자가 낫다.

다만 as나 is 연산자를 사용하면 사용자 정의 형변환은 수행되지 않는다.

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

 

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

다음 코드는 st가 어떤 타입으로 선언되었느냐에 따라 다르게 동작할 수 있다.

 

as 연산자를 그대로 이용하되 nullable 타입으로 형변환을 수행한 후

그 값이 null인지 확인하는 편이 더 낫다.

 

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

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

.NET 기본 클래스 라이브러리(Base Class Library, BCL)에는 시퀀스 내의 개별 요소들을 특정 타입으로 형변환하는 Enumerable.Case<T>()와 같은 함수가 있다.

이 함수는 IEnumerable 인터페이스만을 지원하는 컬렉션에 포함된 각각의 객체에 대해 형변환을 수행할 때 주로 사용한다.

IEnumerable collection = new List<int> {1,2,3,4,5,6,7,8,9,10};

var small = from int item in collection
			where item < 5
            select item;
            
var small2 = collection.Cast<int>().Where(item => item < 5).Select(n => n);

Enumerable.Cast<T> 메서드는 as 연산자 대신 캐스트 연산을 사용하는데

as 연산자를 사용하면 형변환하려는 타입에 제한이 생기기 때문이다.

 

또한 제네릭 컬렉션에 대해서는 Cast<>를 호출할 수 없음에도 주의해야 한다.

 

다양한 사례들을 통해 객체 지향 프로그래밍에서는 가능하면 형변환을 피하는 것이 좋다.

하지만 형변환이 불가피한 경우도 있기 때문이다.

이 경우에는 사용자의 의도를 명확히 표현할 수 있는 is와 as 연산자를 사용하라.

 

✅ string.Format()을 보간 문자열로 대체하라

C# 6.0에 새롭게 도입된 문자열 보간 기능을 사용해보자.

기존에 널리 사용하던 string.Format()은 포맷 문자열에 나타낸 인자의 개수와 실제로 전달되는 인자의 개수가 정확히 일치하는지를 확인하는지 않는다.

보간 문자열을 사용하려면 문자열 앞에 $를 붙이면 된다. 문자열로 변경할 표현식은 { } 내에 둔다.

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

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

따라서 이를 object 타입으로 변경하려면 박싱을 수행해야 한다.

이런 이유로 이 같은 코드를 너무 자주 사용하거나 루프 내에서 사용하게 되면 성능에 좋지 않은 영향을 미칠 수 있다.

따라서 댜ㅏ음과 같이 전달할 인자를 사전에 문자열로 변경하면 값 타입이 박싱되는 것을 피할 수 있다.

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

조건 표현식을 문자열 보간 기능과 같이 사용하게 되면 약간의 충돌이 발생한다.

아래 코드는 컴파일 오류가 발생한다.

Console.WriteLine($"The value of pi is {round ? Math.PI.ToString() : Math.PI.ToString("F2")}");

앞의 코드를 컴파일되도록 변경하려면 컴파일러에게 :이 포맷 문자열이 아니라 조건 표현식의 일부임을 알려야 한다.

Console.WriteLine($"The value of pi is {(round ? Math.PI.ToString() : Math.PI.ToString("F2"))}");

주의)

어떤 경우라도 문자열 보간 기능의 결과가 문자열이다.

모든 값이 대체 되고 단일의 문자열만이 남을 뿐이다.

문자열 보간 기능은 매개변수화된 SQL 쿼리를 생성하지 않으며 단일의 문자열을 만들 뿐이다.

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

✅ 문화권별로 다른 문자열을 생성하려면 FormattableString을 사용하라

문자열 보간 기능은 글로벌화 혹은 지역화에 필요한 거의 모든 기능을 갖추고 있다.

게다가 문화권을 고려하여 문자열을 생성하는 내부적인 복잡함을 잘 감추고 있기도 하다.

문화권을 임의로 지정해야 하는 경우에는 명시적으로 FormattableString 타입의 객체를 생성하도록 코드를 작성하고

이 객체를 통해 문자열을 얻어 오는 방법을 사용하는 것이 좋다.

 

✅ nameOf() 연산자를 적극 활용하라

INotifyPropertyChanged 인터페이스의 구현부

public string Name
{
	get
    {
    	return name;
    }
    set
    {
    	if(value ! = name)
        {
        	
            name = value;
            PropertyChanged?.Invoke(this,
            	new PropertyChangedEventArgs(nameof(Name)));
            
        }
    }
}

nameof() 연산자를 사용했기 때문에 속성의 이름을 변경할 경우 이벤트의 인자로 전달해야 하는 문자열도 쉽게 변경할 수 있다.

특성의 매개변수로 문자열을 전달해야 하는 경우에도 nameof() 연산자를 사용할 수 있다.

이는 MVC 응용 프로그램이나 WebAPI 응용 프로그램 개발 시에 경로(route)를 지정할 때 특히 유용하다.

 

nameof() 연산자를 사용하면 심볼의 이름을 완전히 바꾸거나 수정할 경우에도 손쉽게 그 변경 사항을 반영할 수 있다.

 

✅ 델리게이트를 이용하여 콜백을 표현하라

콜백은 서버가 클라이언트에게 비동기적으로 피드백을 주기 위해서 주로 사용하는 방법이다.

이를 위해 멀티 스레딩 기술도 사용되고, 동기적으로 상태를 갱신하는 기법도 활용된다.

콜백은 C#에서 델리게이트를 이용하여 표현된다.

 

델리게이트를 이용하면 타입 안정적인 콜백을 정의할 수 있다.

 

대부분의 경우에 델리게이트는 event와 함께 사용되지만 반드시 그래야 하는 것은 아니다.

여러 클래스가 상호 통신을 수행해야 할 때 클래스 간의 결합도를 낮추고 싶다면

인터페이스보다 델리게이트를 사용하는 것이 좋다.

델리게이트는 런타임에 통지 대상 설정 가능하고, 다수의 클라이언트에 통지를 보낼 수 있다.

하나의 델리게이트는 여러 메서드에 대한 참조를 포함할 수 있기 때문이다.

각 메서드는 정적 메서드일 수도 있고, 인스턴스 메서드일 수도 있다.

 

즉 델리게이트를 사용하면 통지를 전달하려는 대상이 한 개 일수도 있고, 여러 개일 수도 있다.

런타임에 구성하기 나름이다.

 

콜백과 델리게이트는 C#이 제공하는 관용구의 하나이며 람다 표현식을 사용하는 경우에도 널리 활용된다.

.NET Framework 라이브러리는 Predicate<T>, Action<T>, Func<>와 같은 형태로, 자주 사용되는 델리게이트를 정의해두고 있다.

 

💡 Predicate<T>

조건을 검사하여 부울값을 반환하는 델리게이트

 

💡  Action<T>

여러 개의 매개변수를 받지만 반환 타입이 void인 델리게이트

 

💡 Func<>

여러 개의 매개 변수를 받아 단일의 결괏값을 반환하는 델리게이트

따라서 Func<T, bool>은 Predicate<T>와 동일하다고 볼 수 있다.

 

LINQ는 이러한 개념을 기반으로 만들어졌다.

실제로 List<T>는 콜백을 사용하는 다양한 메서드를 가지고 있다.

List<int> numbers = Enumerable.Range(1,200).ToList();

var oddNumbers = numbers.Find(n => n % 2 == 1);
var test = nubmers.TrueForAll(n => n < 50);

numbers.RemoveAll(n => n % 2 == 0);
numbers.ForEach(item => Console.WriteLine(item));

💡 Find() 메서드

Predicate<int> 형식의 델리게이트를 사용하여 리스트 내에 포함된 요소에 대하여 테스트를 수행한다.

Find() 메서드는 이 콜백을 사용하여 각 항목을 테스트하며, 테스트를 통과한 항복만을 반환한다.

 

 

💡 TrueForAll() 메서드

각 요소를 개별적으로 테스트하되 모든 항목이 테스트를 통과한 경우에만 true를 반환한다.

 

💡 RemoveAll() 메서드

델리게이트에서 정의한 테스트를 통과한 항목들을 리스트에서 제거한다.

 

💡 ForEach() 메서드

리스트 내의 각 요소에 대하여 델리게이트로 지정한 동작을 수행한다.

 

컴파일러는 람다 표현식을 메서드로 변환한 후, 이 메서드를 참조하는 델리게이트를 생성한다.

 

LINQ는 모두 델리게이트를 기반으로 한다.

WPF, WinForms에서 여러 스레드를 넘나들 경우 반드시 마샬링(Marshaling)이 필요한데, 이 경우에도 콜백이 사용된다.

 

👉 마샬링이란?

한 객체의 메모리에서의 표현방식을 저장 또는 전송에 적합한 다른 데이터 형식으로 변환하는 과정

이는 데이터를 서로 다른 프로그램 간에 전달할 필요가 있을 경우 사용한다.

즉 이는 직렬화와 유사하며 멀리 떨어진 객체와 통신하기 위해 사용한다.

클라이언트에서 마샬링된 데이터를 서버나 다른 프로세스에 전달하면, 그 원격 프로세스에서 데이터를 받아 언마샬링하여 사용함으로써 상호 간에 통신하며 데이터를 사용할 수 있다.

 

1. 직렬화된 객체를 바이트 단위로 분해한다.(marshalling)

2. 직렬화되어 분해된 데이터를 순서에 따라 전송한다.

3. 전송받은 데이터를 원래대로 복구한다.(unmarshalling)

 

또한 .NET Framework의 매개 변수로 단일 메서드를 필요로 하는 모든 경우에 람다 표현식을 쓸 수 있도록 델리게이트를 사용한다.

 

역사적인 이유로 모든 델리게이트는 기본적으로 멀티캐스트가 가능하다.

멀티캐스트 델리게이트는 한 번만 호출하면 델리게이트 객체에 추가된 모든 대상 함수가 호출된다.

 

이 때의 주의점

1) 예외에 안전하지 않다.

2) 마지막으로 호출된 대상 함수의 반환값이 델리게이트의 반환값으로 간주된다.

 

이 메서드를 유니캐스트 델리게이트 형태로 사용하면 문제가 없지만,  멀티캐스트 델리게이트 형태로 사용하면 문제가 발생한다.

public void LengthOperation2(Func<bool> pred)
{
	bool bContinue = true;
    foreach(ComplicatedClass cl in container)
    {
    	cl.DoLengthyOperation();
        foreach(Func<Bool pr in pred.GetInvocationList())
        	bContinue & = pr();
            
        if (!bContinue)
        	return;
    }
}

 

✅ 이벤트 호출 시에는 null 조건 연산자를 사용하라

멀티캐스트 델리게이트의 경우 이벤트에 결합된 이벤트 핸들러가 여러 개지만

내부적으로 이를 순차적으로 호출해질 것이므로 복잡성이 외부로 드러나지 않는다.

하지만 실제로는 이처럼 단순한 방식으로 이벤트를 호출하는 경우 다양한 문제가 발생하곤 한다.

이벤트 핸들러가 결합되어 있는지를 확인하는 코드와 이벤트를 발생시키는 코드 사이에 경쟁 조건이 발생할 가능성이 있다.

이 문제는 C# 6.0 에 새롭게 추가된 null 조건 연산자를 사용하면 깔끔하게 해결할 수 있다.

 

public class EventSource
{
	private EventHandler<int> Updated;
    
    public void RaiseUpdates()
    {
    	counter++;
        Updated(this, counter);
    }
    private int counter;
}

이 코드는 문제가 있다.

우선 Updated 이벤트에 이벤트 핸들러가 결합돼 있지 않다면 NullReferenceException 예외가 발생한다.

이벤트 핸들러가 결합되지 않은 이벤트는 null 값을 갖기 때문이다.

 

따라서 이벤트를 발생하기 이전에 유효한 이벤트 핸들러가 결합되었는지를 확인하도록 코드를 추가해야 한다.

public void RaiseUpdates()
{
	counter++;
    if(Updated != null)
    	Updated(this, counter);
}

이렇게 코드를 수정하면 대부분의 경우 잘 동작하지만 여전히 숨어 있는 버그가 있다.

if문을 호출하여 Updated 이벤트가 null이 아님을 확인했다고 하자.

그런데 이벤트를 발생시키는 코드를 수행하기 직전에 다른 스레드가 이벤트 핸들러의 등록을 취소했다고 생각해보자.

다시 원래 스레드로 돌아와 이벤트를 발생시키려 하면 이벤트 핸들러는 null 값을 가지게 되므로 NullReferenceException 예외가 발생한다.

이 같은 오류는 흔히 나타나지 않으며 문제 증상을 재현하기도 쉽지 않다.

 

이러한 버그는 분석하기도 어렵고 문제를 고치는 것도 여간 까다로운 게 아니다.

코드에 문제가 있을 것이라고 가늠하기도 어렵고 오류 재현 환경을 만들려면 여러 스레드 수행과정을 정밀하게 제어해야 하기 때문이다.

숙련된 개발자조차 이러한 코드 구성이 매우 위험하다는 사실을 지독한 경험으로부터 체득할 수밖에 없다.

public void RaiseUpdates()
{
	counter++;
    // 권장되는 코드
    var handler = Updated;
    if(handler != null)
    	handler(this, counter);
}

이 코드는 멀티스레드 환경에서도 안전하게 동작한다.

HOW???

먼저 할당문을 통해 현재 이벤트 핸들러를 새로운 지역변수에 할당했다.

이 지역변수는 멀티캐스트 델리게이트를 포함할 수 있다.

또한 이 델리게이트는 내부적으로 원래 이벤트의 이벤트 핸들러 목록을 그대로 가지고 있을 것이다.

 

이벤트에 대한 할당 구문은 할당문 오른쪽 객체에 대한 얕은 복사본(shallow copy)을 만든다.

이 복사본은 여러 개의 이벤트 핸들러가 포함된 리스트의 복사본을 생성하게 된다.

만약 이벤트 핸들러가 결합되지 않은 경우라면 할당문 오른쪽 값이 null일 것이기 때문에 새로운 변수의 값도 null일 것이다.

만약 다른 스레드가 이벤트에 대한 구독을 취소하면

기존 객체에 포함된 이벤트 필드의 내용은 수정되겠지만 복사된 지역 변수의 내용은 변경되지 않는다.

따라서 지역변수에는 이전에 복사됐던 이벤트 핸들러가 그대로 남아있게 된다.

 

👉이제 복사된 지역 변수의 null 여부 확인해보면 복사가 수행되었던 시점에 이벤트 핸들러가 존재했는지를 확인하게 되므로 모든 이벤트 핸들러가 정상적으로 호출된다.

 

이벤트를 발생시키려는 위치마다 매번 이러한 코드를 반복해서 사용하거나, 혹은 유사한 코드를 포함하고 있지는 private 메서드를 만들어두고 이 메서드를 이용하여 이벤트를 발생시키도록 코드를 작성해야 한다.

이는 마치 필요 없는 코드처럼 보이기도 할뿐더러 이벤트를 발생하기 위해서 이처럼 복잡한 코드를 사용하는 것도 적절해 보이지 않는다.

null 조건 연산자를 사용하면 코드를 매우 간단하게 작성할 수 있다.

public void RaiseUpdates()
{
	counter++;
    Updated?.Invoke(this, counter);
}

?. 연산자의 동작 방식은 연산자의 왼쪽을 평가하여 이 값이 null이 아닌 경우에만 연산자 오른쪽의 표현식을 실행한다.

만약 연산자 왼쪽이 null이면 아무 작업도 수행하지 않고 다음 단락으로 이동한다.

 

이는 if 문을 사용하는 이전 예제와 언뜻 비슷해보이나, 이는 원자적으로 수행된다.

?. 연산자를 이용하여 이벤트를 발생시킬 때는 이벤트 이름 뒤에 ()를 붙여 호출할 수 없으므로 Invoke 메서드를 사용해야 한다.

 

C# 컴파일러는 모든 델리게이트와 이벤트에 대하여 Invoke() 메서드를 타입 안정적 형태로 생성해주므로 이 메서드를 호출하는 것은 ()를 이용하여 이벤트를 직접 발생시키는 코드와 완전히 동일하다.

 

이 코드는 멀티스레드 환경에서도 안전할 뿐 아니라 이전 코드보다 더욱 간결하다.

 

✅ 박싱과 언박싱을 최소화하라

값 타입은 주로 값을 저장할 때 쓰는 저장소이며 다형적이지 못하다.

반면에 .NET Framework는 모든 타입의 최상위 타입을 참조 타입인 System.Object로 정의하고 있다.

이 두 가지는 서로 양립할 수 없는 것처럼 보인다.

하지만 .NET Framework는 박싱과 언박싱이라는 방법을 통해서 이 두 가지 서로 다른 타입을 이어준다.

 

💡박싱

- 값 타입의 객체를 타입이 정해져 있지 않은 임의의 참조 타입 내부에 포함시키는 방법(값 타입 참조 타입으로 변경)

- 이를 이용하면 참조 타입이 필요한 경우에도 값 타입을 쓸 수 있다.

 

💡언박싱

- 박싱과 반대로 박싱되어 있는 참조 타입의 객체로부터 값 타입 객체의 복사본을 가져오는 방법

 

하지만 박싱과 언박싱은 성능에 좋지 않은 영향을 미친다.

때로는 박싱과 언박싱을 수행하면서 임시 객체가 생성되기도 하는데, 간혹 이로 인해 예상치 못한 버그가 발생하기도 한다.

따라서 박싱과 언박싱은 가능한 한 피하는 것이 좋다.

 

제네릭 관련 기능을 활용하면 값 타입에 대한 불필요한 박싱 작업이 수행되지 않도록 코드를 작성할 수 있다.

하지만 .NET Framework의 도처에는 여전히 System.Object 타입의 객체를 요구하는 경우가 있으며, 이러한 API들은 여전히 박싱과 언박싱 작업을 수행한다.

박싱과 언박싱은 모두 자동으로 이뤄진다.

System.Object와 같은 참조 타입을 요구하는 곳에 값 타입의 객체를 사용하면 컴파일러는 자동으로 박싱과 언박싱을 수행하는 코드를 생성한다.

또한 인터페이스를 통해 값 타입의 객체를 참조하는 경우에도 박싱과 언박싱을 수행하는 코드를 생성한다.

하지만 이로 인해 발생하는 성능상의 취약점을 개선하고 싶다면 값 타입의 객체를 직접 전달하지 말고 문자열 인스턴스를 전달하는 것이 좋다.

 

간단한 예)

// 컬렉션 내에서 Person 타입을 사용한다.
var attendees = new List<Person>();
Person p = new Person { Name = "Old Name"};
attendees.Add(p);

// Name을 변경하려 했다.
// Person이 참조 타입이라면 정상 동작한다.
Person p2 = atendees[0];
p2.Name = "New name";

// "Old Name"을 출력한다.
Console.WriteLine(attendees[0].ToString());

int i = 25;
object o = i;
Console.WriteLine(o.ToString());

Person은 값 타입이다.

JIT 컴파일러는 List<Person>과 같이 닫힌 제네릭 타입을 생성하여, attendees 컬렉션에 Person 객체를 저장할 때 박싱이 일어나지 않도록 했다.

하지만 Name 속성을 변경하기 위해서 컬렉션으로부터 Person 객체를 가져오는 순간 새로운 복사본이 생성된다.

이후의 속성 변경 코드는 모두 복사본을 대상으로 이루어진다.

사실 attendees[0]에 대해 ToString() 메서드를 호출할 때 또 다른 복사본이 만들어지기도 한다.

 

제네릭이 아닌 컬렉션 내에 값 타입의 객체를 저장하거나 System.Object 내에 정의된 메서드를 호출하기 위해서 System.Object 타입으로 형변환을 수행하는 것과 같이 값 타입을 System.Object 타입이나 인터페이스 타입으로 변경하는 코드는 가능한 작성하지 말아야 한다.

 

✅ 베이스 클래스가 업그레이드된 경우에만 new 한정자를 사용하라

object c = MakeObject();

// MyClass 타입의 참조를 이용하여 메서드를 호출한다.
MyClass cl = c as MyClass;
cl.MagicMethod();

// MyOtherClass 타입의 참조를 이용하여 메서드를 호출한다.
MyOtherClass cl2 = c as MyOtherClass;
cl2.MagicMethod();

다음과 같이 MyOtherClass에서 new 한정자를 이용하여 MagicMethod()를 재정의했다면 두 메서드의 호출 결과가 달라진다.

public class MyClass
{
	public void MagicMethod()
    {
    	Console.WriteLine("MyClass");
    }
}

public class MyOtherClass : MyClass
{
	// MagicMethod를 재정의
    public new void MagicMethod()
    {
    	Console.Write("MyOtherClass");
    }
    
}

동일한 객체를 이용하여 동일한 메서드를 호출했다면 동일한 작업이 실행되기를 기대하는 것은 당연하다.

메서드를 호출할 때 사용한 참조나 레이블을 변경한다고 해서 동작 방식이 바뀔 것이라고 생각하지는 않을 것이다.

이는 일관성이 없다고 봐야 한다.

 

사실 new 한정자는 비가상 메서드를 가상 메서드로 만드는 것이 아니라 클래스의 명명 범위(naming space)내에 새로운 메서드를 추가하는 역할을 수행한다.

 

💡비가상 메서드

- 정적으로 바인딩

- MyClass.MagicMethod()를 호출하는 코드는 정확히 이 메서드를 호출한다.

 

💡가상 메서드

- 동적으로 바인딩

- 런타임에 객체의 타입이 무엇이냐에 따라 그에 부합하는 메서드를 호출한다.

 

그렇다고 비가상 메서드를 재정하는 new 한정자를 사용하지 않기 위해서 베이스 클래스의 모든 메서드를 가상 메서드로 변경하는 것은 안 될 말이다.

무작정 모든 메서드를 가상으로 선언하는 것은 베이스 클래스의 모든 동작을 파생 클래스에서 임의로 변경할 수 있음을 의미하는 것이며, 이는 파생 클래스가 동작 방식을 변경하는 것에 대해 베이스 클래스가 그다지 신경 쓰지 않음을 의미하는 것과 다를 바가 없다.

 

어떤 메서드를 가상으로 선언하지 않을지를 결정하려면 다형성이 필요한 메서드나 속성이 무엇인지를 우선 생각해보고 반드시 다형성이 필요한 경우에만 가상 메서드를 사용해야 한다. 이처럼 가상 메서드를 제한적으로 사용하는 것이 클래스의 가능성을 제약한다고 생각하기보다는 이 클래스의 활용 방법에 대해서 더 신중한 가이드라인을 제시하는 것으로 생각해야 한다.

 

베이스 클래스에 동일 이름의 메서드가 추가되면, 빌드가 실패한다.

대책

1) 메서드를 새로운 다른 이름으로 변경

베이스 클래스에 새롭게 추가된 메서드가 동일한 작업을 수행하는 경우라면 베이스 클래스의 메서드를 호출

public class MyWidget : BaseWidget
{

	public void NormalizeAllValues()
    {
    	base.NormalizeValues();
    }
}

 

2) new 한정자 사용

public class MyWidget : BaseWidget
{

	public new void NormalizeValues()
    {
    	base.NormalizeValues();
    }
}

MyWidget 클래스를 사용되는 코드를 모두 수정할 수 있다면 장기적인 관점에서 메서드의 이름을 변경하는 것이 좋다.

하지만 MyWidget 클래스를 널리 공개한 경우라서 누가 이 클래스를 사용하고 있는지조차 알 수 없다면 BaseWidget.Normalize()를 호출하도록 코드를 수정하라고 강요하기보다 new 한정자를 이용하여 기존 메서드명을 그대로 사용할 수 있도록 하는 편이 낫다.

이처럼 new 한정자를 이용하면 베이스 클래스가 업그레이드되어 파생 클래스의 멤버와 이름이 충돌하는 경우 간단히 문제를 해결할 수 있다.

때로는 당장의 불편함을 감수하더라도 메서드의 이름을 변경하는 것이 장기적으로 보면 더 나은 대안일 수 있다.

 

new 한정자를 사용할 때는 각별한 주의가 필요하다.