[Effective C#] Ch3. 제네릭 활용

2022. 4. 13. 11:35카테고리 없음

제네릭이 컬렉션과 함께 사용될 때만 유용한 것처럼 말하곤 하지만

컬렉션 외에도 인터페이스, 이벤트 핸들러, 공통 알고리즘 구현 등 매우 다양한 분야에서 유용하게 활용될 수 있다.

제네릭을 사용하기 위해 C# 컴파일러, JIT 컴파일러, CLR 컴파일러는 모두 수정되어야 했다.

 

 

✨ C# 컴파일러

제너릭 타입으로 작성한 코드를 적절한 MSIL(Microsoft Intermediate Language)(플랫폼에 무관한 중간 언어)로 생성하기 위해 수정

 

JIT 컴파일러

정의

- MSIL은 사용 이전에 반드시 컴파일 되어야만 하는데, 이 때 컴파일하는 역할 수행

- MSIL 명령어 집합을 컴파일해서 기계 코드로 바꾸어주면, 이를 메모리 상의 캐쉬에 저장

- 이를 통해 특정 함수를 호출 시에 재컴파일할 필요없도록 함

- 닫힌 제네릭 타입을 생성하기 위해서 제네릭 타입에 대한 정의부와 타입 매개변수를 결합할 수 있도록 수정

 

✨ CLR

- 런타임에 이 두 가지를 모두 지원하기 위해서 모두 변경

 

타입을 제네릭으로 정의하면 장점도 있고 그에 따른 비용도 발생한다.

 

✨ 제네릭 타입 정의

타입을 제네릭의 형태로 정의하는 것

- 닫힌 제네릭 타입: 타입 매개변수에 구체적인 타입을 지정

- 열린 제네릭 타입: 여러 타입 매개변수 중 일부만 구체적인 타입을 지정

 

✨ 컴파일 과정에서 역할

- IL: 제네릭은 타입을 부분적으로 정의한 형태

- JIT 컴파일러: 닫힌 제네릭 타입으로 객체를 인스턴스화하기 위한 코드를 생성할 때 비로소 타입의 정의를 완성

 

제네릭 타입에 따라 달라지는 컴파일과정

제네릭 타입 X)

- 클래스를 표현하는 IL

- 생성되는 머신 코드

 

위 둘이 일대일 관계를 이룬다.

제네릭 타입 O)

- JIT 컴파일러가 제네릭 클래스를 만나게 되면 타입 매개변수로 주어진 타입을 확인

- 타입 매개변수에 서로 다른 값 타입이 주어지는 경우, 서로 다른 머신 코드를 생성

- 해당 타입에 가장 적합한 머신 코드를 생성

 

 

✅ 반드시 필요한 제약 조건만 설정하라

참고: MSDN 형식 매개 변수에 대한 제약 조건

⭐ 타입 매개변수에 대한 제약 조건

클래스가 작업을 올바르게 수행하기 위해서 타입 매개변수로 전달할 수 있는 타입의 유형을 제한하는 방법

제약 조건을 설정하지 않으면 런타임에 더 많은 검사를 수행할 수밖에 없다.

더 자주 형변환을 해야 하고, 리플렉션을 사용해야 할 가능성이 커지고, 잘못된 타입으로 인해 런타임 오류가 발생할 가능성 또한 높아진다.

반면에 불필요한 제약 조건을 설정하면 이 클래스를 사용하기 위해서 과도하게 추가 작업을 해야 한다.

 

제약 조건을 설정하면 컴파일러는 System.Object에 정의된 public 메서드보다 더 많은 것을 타입 매개변수에 기대할 수 있게 된다.

C# 컴파일러는 제네릭 타입에 대해 올바른 IL을 생성해야할 책임이 있다.

따라서 타입 매개변수로 어떤 타입을 지정할 것인지에 대한 추가 정보가 제공되지 않는다면 컴파일러는 이를 System.Object가 정의하고 있는 최소한의 기능만을 제공하는 타입이라고 가정할 수밖에 없다.

 

제약 조건은 제네릭 타입에 대해 우리가 가정하고 있는 사실을 컴파일러와 다른 개발자에게 알려주는 용도로 사용된다.

컴파일러에게 제약 조건을 알려준다

= 제네릭 타입에서 타입 매개변수로 주어진 타입을 System.Object에서 노출하는 수준 이상으로 사용할 수 있음을 알려줌

= 두 가지 측면에서 도움

1) 제네릭 타입 작성 시

컴파일러는 타입 매개변수로 전달된 타입이 제약 조건으로 설정한 기능을 모두 구현하고 있을 것이라 가정할 수 있다.

 

2) 컴파일러는 제네릭 타입을 사용하는 사용자가 타입 매개변수로 올바른 타입을 지정했는지를 컴파일타임에 확인

ex)

- 타입 매개변수가 반드시 struct이어야 함을 지정할 수도 있고 , 반드시 class이어야함을 지정 가능

- 타입 매개변수로 주어진 타입이 반드시 구현해야 하는 인터페이스의 목록을 제시할 수 있으며 베이스 클래스의 타입을 제약 가능

 

때로는 제약 조건을 설정하여 해당 클래스의 사용을 어렵게 만드느니 런타임에 특정 인터페이스를 구현하고 있는지 혹은 베이스 클래스를 상속한 타입인지를 확인한 후 사용하는 것이 좋은 경우도 있다.

 

기본 생성자 제약 조건을 설정할 때는 추가적으로 주의해야 하는 부분- 때때로 new 대신 default()를 사용하면 new () 제약 사항이 필요 없을 수도 있다.C#의 default() 연산자: 특정 타입의 기본값을 가져오는데, 값 타입에 대해서는 0을, 참조타입에 대해서는 null을 가져온다.따라서 new()를 default()로 바꾸면 값 타입과 참조 타입에서 모두 사용 가능하다.

 

✅ 런타임에 타입을 확인하여 최적의 알고리즘을 사용하라

제네릭 타입의 경우 타입 매개변수에 새로운 타입을 지정하여 손쉽게 재사용할 수 있다.

그런데 알고리즘이 특정 타입에 대해 더 효율적으로 동작한다고 생각된다면 그냥 그 타입을 이용하도록 코드를 작성한다.

 

✅ IComparable<T>와 IComparer<T>를 이용하여 객체의 선후 관계를 정의하라

.NET Framework는 객체의 선후관계를 정의하기 위해서 IComparable<T>와 IComparer<T> 2개의 인터페이스를 제공한다.

⭐IComparable<T>

- 타입의 기본적인 선후 관계 정의

- CompareTo()라는 단 하나의 메서드만이 정의

 

⭐IComparer<T>

- 기본적인 선후 관계 이외에 추가적인 선후 관계를 정의

- 우리가 직접 개발하지 않는 타입에 대해서도 임의의 선후 관계를 추가로 정의할 수 없다.

 

타입 내에 관계 연산자(> , < <=, >=)를 재정의하면 해당 타입에 최적화된 방식으로 객체의 선후관계를 판단한다.

.NET 환경이 제공하는 최신 API들은 대체로 IComparable <T> 를 사용하지만 일부 오래된 API들은 여전히 IComparable을 사용한다.

따라서 IComprable<T>를 구현할 때는 IComparable도 함께 구현해야 한다.

타입 매개변수를 취하지 않은 IComparable은 상당히 많은 단점이 있다.이 인터페이스를 구현하려면 매개변수에 대한 타입을 런타임에 확인해야 한다.설사 올바른 객체를 전달한다 하더라도실제 비교를 위해서는 박싱/언박싱이 필요하므로 매 비교 시마다 상당한 성능 비용이 발생한다.

 

선후 관계의 비교와 동일성의 비교는 사실 별개의 작업이며 선후 관계를 비교하기 위해서 동일성 비교 기능을 반드시 함께 구현해야 할 필요는 없다.

 

정리IComparable과 IComparer은 타입에 선후 관계를 제공하기 위한 표준 매커니즘이다.기본적인 선후관계는 IComparable을 통해 구현해야 한다.IComparable 구현할 때는 관계 연산자도 함께 오버 로딩하여 일관된 결과를 제공해야 한다.IComparable.CompareTo()는 System.Object 타입의 매개변수를 취하므로 별도로 오버로딩된 메서드를 제공해야 한다.

 

 

✅ 타입 매개변수가 IDisposable을 구현한 경우를 대비하여 제네릭 클래스를 작성하라

제약 조건의 역할

1) 런타임 오류가 발사앻라 가능성이 있는 부분을 컴파일 오류로 대체할 수 있다.

2) 타입 매개변수로 사용할 수 있는 타입을 명확히 규정하여 사용자에게도 도움을 준다.

 

대부분의 경우 타입 매개 변수로 지정하는 타입이 제약 조건을 통해 요구하는 작업 외에 다른 작업을 추가로 수행할 수 있는지에 대해 신경 쓰지 않는다.

하지만 타입 매개 변수로 지정하는 타입이 IDisposable을 구현하고 있다면 특별한 추가 작업이 반드시 필요하다.

 

만약 T가 IDisposable을 구현한 타입일 경우 리소스 누수가 발생할 수 있다.

따라서 T 타입으로 지역변수를 생성할 때마다 T가 IDisposable을 구현하고 있는지 확인해야 하며, 만약 IDisposable을 구현하고 있다면 추가적인 처리를 해야 한다.

 

public void GetThingsDone()
{
  T driver = new T();
  using(driver as IDsiposable)
  {
    driver.DoWork();
  }
}

이처럼 코드를 작성하면 컴파일러는 IDisposable로 형변환된 객체를 저장하기 위해서 숨겨진 지역변수를 생성하낟.

만약 T가 IDisposable을 구현하지 않았다면, 이 지역변수의 값은 null이 된다.

C# 컴파일러는 이 지역변수의 값이 null인지 검사한 후 Dispose()를 호출하도록 생성하기 때문에 지역변수의 값이 null인 경우 Dispose()가 호출되지 않는다. 반대로 T가 IDiposable을 구현했다면 using 블록을 종료할 때 Dispose() 메서드가 호출된다.

 

타입 매개변수로 주어진 타입을 이용하여 인스턴스를 생성한다면 반드시 앞에서와 같이 using 문을 사용해야 한다.

또한 해당 타입이 IDisposable을 구현했을지 알 수 없으므로 반드시 앞에서와 같은 형변환 코드가 필요하다.

 

타입 매개 변수로 멤버 변수를 선언해야 하는 경우라면 지연 생성을 사용해야할 수도 있다.

private Lazy<T> driver = new Lazy<T>(() => new T());

항상 방어적으로 코드를 작성하고 객체가 삭제될 때 리소스가 누수되지 않도록 주의해야 한다.

혹은 코드를 완전히 수정하여 타입 매개변수로 객체를 생성하지 않도록 응용 프로그램의 구조를 변경할 수도 있다.

그렇게 하고 싶지 않다면 타입 매개변수로는 지역 변수 정도만을 생성하도록 코드를 작성하는 것이 좋다.

마지막으로 타입 매개변수로 멤버 변수를 선언해야 하는 경우라면 지연 생성을 사용해야 할 수도 있고, 제네릭 클래스에서 IDisposable을 구현해야 할 수도 있다.

 

✅ 공변성과 반공변성을 지원하라

타입의 가변성(variance), 즉 공변(covariance)과 반공변(contravariacne)은 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격을 일컫는다.

이러한 변환을 지원하려면 제네릭 인터페이스나 델리게이트의 정의 부분에 

제네릭 공변/반공변을 지원한다는 의미의 데코레이터(decorator)를 추가해야 한다.

공변/반공변을 지원하면 우리가 개발하는 API를 더 다양하고 안전하게 사용할 수 있다.

 

공변

X ➡ Y = C<X> ➡ C<Y>

- 자신과 자식으로만 형변환

- out 키워드로 지정

반공변

Y ➡ X = C<X> ➡ C<Y>

- 자신과 부모로만 형변환

- in 키워드로 지정

 

public interface IEnumerable<out T>; IEnumerable
{
	new IEnumerator<T> GetEnumerator();
}

public interface IEnumerator<out T> : IDisposable, IEnumerator
{
	new T Current { get; }
}

IEnumerator<T>가 중요한 제약사항을 갖고 있기 때문에 IEnumerable<T>와 IEnumerator<T> 정의를 모두 나타냈다.

IEnumerator<T>를 정의할 때 T에 대해 out 데코레이터가 사용되었음에 주목하자.

= 타입 매개변수 T를 출력 위치에서만 사용하겠다고 컴파일러에게 알려준다.

 

 

✨ 출력 위치함수의 반환값, 속성의 get 접근자, 그리고 델리게이트의 일부 위치에서만 T를 사용할 수 있음을 말한다.

 

IEnumerator<T>가 공변이므로 IEnumerable<T>도 공변이 될 수 있다.IEnumerable<T>가 공변이 아닌 인터페이스를 반환한다면 컴파일러가 에러를 일으킨다.

 

이제 반공변 제네릭 인터페이스와 델리게이트도 만들 수 있다.

out 데코레이터를 in 데코레이터로 변경하기만 하면 된다.

in을 사용하면 컴파일러에게 타입 매개변수를 입력 위치에서만 사용할 것이라고 알려주게 된다.

 

마지막으로 델리게이트의 매개변수에 대한 공변/반공변에 대해서 알아보자.

델리게이트의 매개변수를 정의할 때도 공변/반공변을 모두 사용할 수 있다.

 

메서드의 매개변수 타입은 반공변(in)이고, 메서드의 반환타입은 공변(out)이다.

 

추가 공부)

C#에서 공변성과 반공변성은 배열 형식, 대리자 형식 및 제네릭 형식 인수에 대해 암시적 참조 변환을 가능하게 한다.

공변성은 할당 호환성을 유지하고 반공변성은 할당 호환성은 유지하지 않는다.

// Assignment compatibility.
// 할당 호환성
string str = "test";  
// An object of a more derived type is assigned to an object of a less derived type.
object obj = str;  
  
// Covariance.
// 공변성
IEnumerable<string> strings = new List<string>();  
// An object that is instantiated with a more derived type argument
// is assigned to an object instantiated with a less derived type argument.
// Assignment compatibility is preserved.
// 할당 호환성은 유지된다.
IEnumerable<object> objects = strings;  
  
// Contravariance.
// 반공변성
// Assume that the following method is in the class:
// static void SetObject(object o) { }
Action<object> actObject = SetObject;  
// An object that is instantiated with a less derived type argument
// is assigned to an object instantiated with a more derived type argument.
// Assignment compatibility is reversed.
// 할당 호환성은 보류된다.
Action<string> actString = actObject;

배열에 대한 공변성은 더 많이 파생된 형식의 배열을더 적게 파생된 형식의 배열로 암시적으로 변환을 가능케 한다.

하지만 다음 코드 예제와 같이 이 작업은 안전하지 않다.

object[] array = new String[10];
// 다음 구문은 런타임 예외 발생한다.
// array[0] = 10;

 

참고

https://docs.microsoft.com/ko-kr/dotnet/csharp/programming-guide/concepts/covariance-contravariance/

 

✅타입 매개변수에 대해 메서드 제약 조건을 설정하려면 델리게이트를 활용하라

제약 조건 예시)

어떤 제네릭 클래스에 대해 타입 매개변수 T가 반드시 Add() 메서드를 가져야 한다는 제약 조건을 설정하고 싶다면...

1)  Add() 메서드 하나를 호출하기 위해서 IAdd<T> 인터페이스를 구현한 새로운 클래스를 생성해야 하는 것

사용자에게 혼란만 가중할 뿐

 

2) 제약 조건으로 설정하고 싶은 메서드의 원형에 부합하는 델리게이트를 작성

public static class Example
{
	public static T Add<T>(T left, T right, Func<T, T, T> AddFunc) => 
    	AddFunc(left, right);
}

이 클래스의 사용자는 람다 표현식을 이용하여 제네릭 클래스가 호출할 AddFunc 메서드를 정의하면 된다.

Add를 호출하는 코드는 다음과 같다.

int a = 6;
int b = 7;
int sum = Example.Add(a, b, (x, y) => x + y);

C# 컴파일러는 람다 표현식으로부터 매개변수의 타입과 반환 타입을 모두 추론한다.

이 예제의 경우 C# 컴파일러는 내부적으로 두 정숫값의 합을 반환하는 private 정적 메서드를 생성한다.

이 메서드의 이름은 컴파일러에 의해서 명명된다.

다음으로 컴파일러는 Func<T, T, T> 델리게이트 타입의 객체를 만들어서 컴파일러가 생성한 메서드를 가리키도록 한다.

마지막으로 Example.Add에 이 델리게이트를 전달한다.

 

✅ 베이스 클래스나 인터페이스에 대해서 제네릭을 특화하지 말라

제네릭 메서드가 등장함에 따라 여러 개의 오버로드된 메서드가 있는 경우, 이 중 하나를 선택하는 과정이 꽤 복잡해졌다.

컴파일러는 요청된 메서드의 원형과 정확히 일치하는 메서드를 생성할 것이기 때문에 베이스 클래스 타입의 매개변수를 취하는 메서드보다 우선적으로 선택된다.

 

베이스 클래스와 이로부터 파생된 클래스에 대해서 모두 수행 가능하도록 하기 위해서 베이스 클래스를 이용하여 제네릭을 특화하려는 시도는 바람직하지 않다.

 

제네릭 메서드의 타입 매개변수로 특정 타입이 주어질 경우 그에 부합하도록 제네릭 특화를 수행하기로 결정했다면,

해당 타입뿐 아니라 이 타입을 상속한 모든 파생 타입에 대해서도 특화를 수행해야 한다.

인터페이스에 대해 특화를 수행하기로 결정했다면 이 인터페이스를 구현하고 있는 모든 타입에 대해서도 특화를 수행해야 한다.

 

 

✅ 타입 매개변수로 인스턴스 필드를 만들 필요가 없다면 제네릭 메서드를 정의하라

제네릭 메서드를 정의하면 타입 매개변수에 대한 제약 조건을 메서드 수준으로 지정할 수 있다.

반면 제네릭 클래스를 정의하면 클래스 전체에 대하여 제약 조건을 고려해야만 한다.

 

타입 매개변수로 인스턴스 필드로 만들어야 하는 경우에는 제네릭 클래스를 작성하고 그렇지 않은 경우에는 제네릭 메서드르 작성하라.

 

Min, Max에 대하여 여러 개의 오버로딩 메서드를 작성한다.

타입을 구체적으로 지정한 메서드는 제네릭 버전보다 효율적으로 동작한다.

그리고 이 메서드를 사용할 때에도 더 이상 타입 매개변수를 명시적으로 지정할 필요가 없다.

 

제네릭 메서드를 사용하는 것이 무조건 장점만 있는 것은 아니다.

다음의 두 가지 경우에는 반드시 제네릭 클래스를 만들어야 한다.

 

1) 클래스 내에 타입 매개변수로 주어진 타입으로 내부 상태 유지

2) 제네릭 인터페이스를 구현하는 클래스를 만들어야 할 경우

 

✅ 제네릭 인터페이스와 논제네릭 인터페이스를 함께 구현하라

 

✅ 인터페이스는 간략히 정의하고 기능의 확장은 확장 메서드를 사용하라

 

✅ 확장 메서드를 이용하여 구체화된 제네릭 타입을 개선하라

컬렉션을 사용하는 이유

1) 특정 타입의 집합을 다뤄야 하는 경우

2) 컬렉션이 제공하는 고유의 기능을 활용하기 위함

 

기존에 사용 중인 컬렉션 타입에 영향을 주지 않으면서 새로운 기능을 추가하고 싶다면 구체화된 컬렉션 타입에 대해 확장 메서드를 작성하면 된다.

 

확장 메서드로 구현하면 람다 표현식을 이용하여 재사용 가능한 쿼리를 작성할 수 있다.

구체화된 제네릭 타입의 컬렉션을 사용한 경우가 비일비재할 것이다.

만약 이런 코드가 있다면 구체화된 제네릭 타입을 살펴보고 논리적으로 어떤 메서드가 추가돼야 하는지를 재검토해야 한다.

구체화된 제네릭 타입을 상속하여 메서드를 추가하기보다는 확장 메서드를 구현하는 편이 훨씬 낫다.