Effective C# 3판 스터디, 그 세 번째 포스트

📚 목차

  • 🔸 ITEM 18: 반드시 필요한 제약 조건만 설정하라.
  • 🔸 ITEM 19: 런타임에 타입을 확인하여 최적의 알고리즘을 사용하라.
  • 🔸 ITEM 20: IComparable <T>IComparer <T>를 이용하여 객체의 선후 관계를 정의하라.
  • 🔸 ITEM 21: 타입 매개변수가 IDisposable을 구현한 경우를 대비하여 Generic 클래스를 작성하라.
  • 🔸 ITEM 22. 공변성과 반공변성을 지원하라.
  • 🔸 ITEM 23. 타입 매개변수에 대해 메서드 제약 조건을 설정하라면 Delegate를 활용하라.
  • 🔸 ITEM 24. 베이스 클래스나 인터페이스에 대해서 Generic을 특화하지 말라.
  • 🔸 ITEM 25. 타입 매개변수로 인스턴스 필드를 만들 필요가 없다면 Generic 메서드를 정의하라.
  • 🔸 ITEM 26. Generic 인터페이스와 Non-Generic 인터페이스를 함께 구현하라.
  • 🔸 ITEM 27. 인터페이스는 간략히 정의하고 기능의 확장은 확장 메서드를 사용하라.
  • 🔸 ITEM 28. 확장 메서드를 이용하여 구체화된 Generic 타입을 개선하라.




제네릭(Generic)이란 형식 매개변수(Type parameter)로서,

클래스나 메서드를 작성할 경우, 이를 호출하기 전까지 데이터 형식 지정을 연기 할 수 있도록 하는 기법이다.

.NET 런타임이 Generic 타입을 JIT 방식으로 컴파일할 때 실제 타입 매개변수에 값 타입이 지정되면 다음과 같은 일이 일어난다.

1. 닫힌 Generic 타입을 표현하기 위한 새로운 IL 클래스를 생성한다. Generic T 인자가 구체 타입으로 대체된다.

2. 대체된 타입을 이용하여 실제 기계어 코드를 작성한다.

Generic 동작 과정

어셈블리가 로드되는 시점이 아니라 로드된 타입의 특정 메서드가 최초로 호출되는 시점에

호출 메서드에 대해서만 JIT 컴파일이 이루어지고 메서드 IL 코드가 기계어 코드로 대체된다고 한다.




🔸 ITEM 18: 반드시 필요한 제약 조건만 설정하라.

Generic에 대한 제약 조건은 클래스가 작업을 올바르게 수행하기 위해서

Generic으로 전달할 수 있는 타입의 유형을 제한하는 방법이다. ( Where 구문 )

물론 이런 조건을 설정하는 대신, Casting이나 런타임에 테스트를 수행하도록 작성할 수도 있다.

그러나 제약 조건으로 작성하면 런타임에 발생할 가능성이 있는 오류를 컴파일타임에 확인할 수 있고 코드 또한 매우 짧아진다.

예를 들면, 테스트 기반의 코드는 아래와 같다.

// 형변환이나 런타임에 테스트 수행
public static bool AreEqual<T>(T left, T right)
{
    if (left == null)
        return right == null;
    if (left is IComparable<T>)
    {
        IComparable<T> lval = left as IComparable<T>;
        if (right is IComparable<T>)
            return lval.CompareTo(right) == 0;
        else
            throw new ArgumentException("Type does not implement IComparable<T>", nameof(right));
    }
    else // 실패
    {
        throw new ArgumentException("Type does not implement IComparable<T>", nameof(left));
    }
}

이렇게 긴 코드를 제약 조건을 통해 다음과 같이 작성할 수 있다.

// 제약 조건 (컴파일 단계에서도 알 수 있음)
public static bool AreEqual2<T>(T left, T right)
    where T : IComparable<T> => left.CompareTo(right) == 0;


이처럼 다양한 장점이 있지만 제약 조건을 과도하게 설정하는 것은 좋지 않다.

많은 제약 조건을 사용하면 Generic 타입을 사용하는 것이 큰 부담이 되고,

오히려 추가 작업을 수행함으로써 더 복잡해질 수 있기 때문이다.


그렇다면 어떻게 제약 조건을 줄여야 할까?

  • Generic 타입 내에서 반드시 필요한 기능만을 제약 조건으로 설정한다.

  • 원하는 메서드가 구현되어 있지 않을 때 런타임에 특정 인터페이스를 구현하고 있는지 혹은 특정 베이스 클래스를 상속한 타입인지 확인하는 것이 좋은 경우도 있다.




🔸 ITEM 19: 런타임에 타입을 확인하여 최적의 알고리즘을 사용하라.

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

하지만, 타입이나 메서드를 Generic화하면 구체적인 타입이 주는 장점을 잃어버리게 되며,

해당 타입의 세부적인 특성까지 고려하여 최적화한 알고리즘을 사용할 수 없게 된다.


만약 어떤 알고리즘이 특정 타입에 대해 더 효율적으로 동작한다고 생각되면 그냥 그 타입을 이용해도 된다.

예를들어, 역순회 방식을 제공하는 ReverseEnumeraterReverseEnumerable을 설계한다고 가정하자.

public sealed class ReverseEnumerable<T> : IEnumerable<T>
{
    private IEnumerable<T> srcSequence;
    private IList<T> originalSequence;
    
    public ReverseEnumerable(IEnumerable<T> sequence)
    {
        srcSequence = sequence;
    }
    
    // IEnumerable의 인터페이스
    public IEnumerator<T> GetEnumerator()
    {
        if(originalSequence == null)
        {
            originalSequence = new List<T>();
            foreach (T item in srcSequence)
                originalSequence.Add(item);
        }

        return new ReverseEnumerator(originalSequence);
    }

    System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => this.GetEnumerator();

    private class ReverseEnumerator : IEnumerator<T>
    {
        int currentIdx;
        IList<T> collection;

        public ReverseEnumerator(IList<T> srcCollection)
        {
            collection = srcCollection;
            currentIdx = collection.Count;
        }

        public T Current => collection[currentIdx];

        object System.Collections.IEnumerator.Current => this.Current;

        // IEnumerator<T>와 IDisposable 인터페이스를 구현한다.
        public void Dispose() { }
        public bool MoveNext() => --currentIdx >= 0;
        public void Reset() => currentIdx = collection.Count;
    }
}


여기서, ReverseEnumerableGetEnumerator를 호출할 때

origianlSequence를 새로 생성해야했는데, 만약 전달된 IEnumerable<T>IList<T>인 것이 확인되면

새로 생성 비용없이 그대로 사용할 수 있을 것이며 아닐 경우에만 새로 생성하면 될 것이다.


따라서, 다음과 같이 생성자를 다듬을 수 있다.

public ReverseEnumerable(IEnumerable<T> sequence)
{
    srcSequence = sequence;

    // 타입을 확인하여 가능하면 컬렉션을 가져다 사용할 수 있을 것이다.
    originalSequence = sequence as IList<T>;
}

// 이미 List<T>로 확인된 타입은 타입을 확인할 필요가 없다.
public ReverseEnumerable(List<T> sequence)
{
    srcSequence = sequence;
    originalSequence = sequence;
}




🔸 ITEM 20: IComparable <T>IComparer <T>를 이용하여 객체의 선후 관계를 정의하라.

IComparable 인터페이스를 구현하면 해당 클래스 인스턴스간의 선후 관계를 알 수 있다.

해당 선후 관계는 해당 클래스를 사용하는 컬렉션을 정렬할 때 사용하게 된다.


IComparable 인터페이스에는 CompareTo() 메서드만 정의되어 있는데,

Compare()는 현재 객체가 대상 객체보다 작으면 0보다 작은 값을, 같으면 0을, 크면 0보다 큰 값을 반환한다.

항상 헷갈리는 부분이다.


IComparable을 구현할 땐, 다음 2가지 사항을 기억해야 한다.

  • .NET 환경이 제공하는 최신 API는 IComparable <T>를 사용하지만 일부 오래된 API는 여전히 IComparable을 사용하기 때문에 IComparable <T>를 구현할 때는 IComparable도 함께 구현해야 한다.

  • IComparable을 구현할 때는 관계 연산자도 함께 오버로딩하여 일관된 결과를 제공해야 한다.


예를 들면, 다음과 같다.

// IComparable<T>와 IComparable 모두 구현
public struct Customer : IComparable<Customer>, IComparable
{
    private readonly string name;

    // IComparable<Customer> 멤버
    public int CompareTo(Customer other) => name.CompareTo(other.name);

    // IComparable 멤버
    int IComparable.CompareTo(object obj)
    {
        if (!(obj is Customer))
            throw new ArgumentException("Argument is not a Customer", "obj");

        Customer other = (Customer)obj;

        return this.CompareTo(other);
    }
}

연산자를 오버로딩 하는 코드는 다음과 같다.

public static bool operator <(Customer left, Customer right) => left.CompareTo(right) < 0;
public static bool operator >(Customer left, Customer right) => left.CompareTo(right) > 0;
public static bool operator <=(Customer left, Customer right) => left.CompareTo(right) <= 0;
public static bool operator >=(Customer left, Customer right) => left.CompareTo(right) >= 0;




🔸 ITEM 21: 타입 매개변수가 IDisposable을 구현한 경우를 대비하여 Generic 클래스를 작성하라.

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

예를 들어 Generic 메서드 내에서 타입 매개변수로 주어지는 타입을 이용하여 인스턴스를 생성할 경우에 발생한다.

TIDisposable을 구현할 타입일 경우 (비관리 자원일 경우) 리소스 누수가 발생할 수 있으므로 추가적인 처리가 필요하다.


타입 매개변수로 주어진 타입을 이용하여 인스턴스를 생성한다면 반드시 using 문을 사용하는것이 좋다.

public class EngineDriverOne<T> where T : IEngine, new()
{
    public void GetThingsDone()
    {
        T driver = new T();
        using (driver as IDisposable)
        {
            driver.DoWork();
        }
    }
}

using 키워드는 IDisposable 타입에 대하여 감싼 코드를 벗어나면 Dispose()를 호출하는 코드를 생성한다.




🔸 ITEM 22. 공변성과 반공변성을 지원하라.

참조 : C# 공변성과 반공변성 Covariance / Contravariance

공변성(Convariance) / 반공변성 (Contravariance)는 C#의 인터페이스와 대리자에 적용되는

Generic 타입의 형변환에 관한 기준이다.

예를 들면, Base 기반 클래스와 Derived 파생 클래스가 있다하자.

public class Base {}
public class Derived : Base {}


🔹 공변성 (Convariance)

일반적인 다형성 참조 형식의 캐스팅에도 사용되는 방식으로

파생 타입의 인스턴스를 기반 타입으로 참조할 수 있다.

IEnumerable<Derived> d = new List<Derived>();
IEnumerable<Base> b = d;  // 문제 없음


🔹 반공변성 (Contravariance)

반대로 기반 타입 인스턴스를 파생 타입으로 참조한다.

일반적인 다형성 참조 형식에는 적용되지 않는다.

대리자(Delegate)의 Generic 타입에만 적용된다.

Action<Base> b = (target) => { Console.WriteLine(target.GetType().Name); };
Action<Derived> d = b;
d(new Derived());  // Action<Derived> d에 Action<Base> 타입 인스턴스 b를 대입

반대로 타입을 지정할 수 있는 이유는 기존 대리자 타입이 Base이기 때문이다.

d는 항상 Derived 타입 인스턴스로 호출이 가능하고 d가 호출시 등록된 대리자 b가 호출된다.

b는 Base 타입 인스턴스를 인자로 받고 Derived는 Base 타입으로 안전하게 캐스팅이 가능하므로

문제 없이 Action 타입 b를 호출할 수 있다.


공변성과 반공변성을 지원하는 것을 가변성(variant)이라고 지칭하고,

둘다 지원하지 않는다면 불변성(invariant)이라고 한다.

📌 CLR이 제공하는 공변성과 반공변성의 특징은 다음과 같다.

  • 가변성 타입(공변성/반공변성)은 Generic 인터페이스와 대리자 형식에만 사용된다.

  • Generic 인터페이스 혹은 대리자는 공변성과 반공변성을 모두 가질 수 있다.

  • 가변성은 참조 타입에만 적용되므로 가변 타입에 값 타입을 사용한다면, 만들어진 인터페이스나 대리자는 불변성이 된다.




🔸 ITEM 23. 타입 매개변수에 대해 메서드 제약 조건을 설정하라면 Delegate를 활용하라.

특정 메서드를 구현하기위해서 인터페이스를 설정하기보다,

해당 메서드를 호출부에서 구현하는 것이 더 편리할때가 있다.

예를 들면, 특정 매개변수를 받는 생성자 시퀀스 함수를 호출할 때 타입 제약조건으로 매개변수가 있는 생성자를 설정할 순 없다.

하지만 여기서 Delegate를 사용하면 제약을 둘 수 있다.


public class Point
{
    public double X { get; }
    public double Y { get; }
    public Point(double x, double y)
    {
        this.X = x;
        this.Y = y;
    }
}

Point클래스는 double타입의 매개변수 두 개를 요구하는 생성자를 가진다.

Point 클래스를 생성할 때 해당 매개변수 생성자를 사용할 수 있도록 제약할 수 있다.

다음 Zip메서드는 Func<T1, T2, TResult> 대리자를 이용하고

실제 Point를 생성하는 (x,y) => new Point(x,y) 람다 표현식을 전달한다.

public static class Utilities
{
    // Func<T1, T2, TResult> 활용
    public static IEnumerable<TResult> Zip<T1, T2, TResult>(
        IEnumerable<T1> left, IEnumerable<T2> right, Func<T1, T2, TResult> generator)
    {
        IEnumerator<T1> leftSequence = left.GetEnumerator();
        IEnumerator<T2> rightSequence = right.GetEnumerator();

        while(leftSequence.MoveNext() && rightSequence.MoveNext())
        {
            yield return generator(leftSequence.Current, rightSequence.Current);
        }

        leftSequence.Dispose();
        rightSequence.Dispose();
    }
}

static void Main(string[] args)
{
    double[] xValues = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
    double[] yValues = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };

    // Delegate 전달
    List<Point> values = new List<Point>(Utilities.Zip(xValues, yValues, (x, y) => new Point(x, y)));
}




🔸 ITEM 24. 베이스 클래스나 인터페이스에 대해서 Generic을 특화하지 말라.

오버로드된 메서드가 여러개인 경우, 컴파일러가 이 중 하나를 어떻게 선택하는지 정확히 알고있어야 한다.

잘못된 오버라이드 함수가 선택되었을때 메서드 내에서 타입확인을 하여 런타임에러를 방지할 수 있겠지만,

Generic은 본래 런타임에 타입확인을 수행하지 않기 위해 만들어졌다는 점을 기억해야한다.


Generic 메서드를 특화하면 어떤 함수가 호출되어 어떤 행동을 기대하기가 쉽지 않다.

정해진 방식이 있으나 Generic 메서드를 이용하는 클래스를 사용할 때 주의해야하고

파생된 클래스를 만들때 해당 Generic 메서드의 특화를 같이 구현해야한다.



🔸 ITEM 25. 타입 매개변수로 인스턴스 필드를 만들 필요가 없다면 Generic 메서드를 정의하라.

Generic 클래스 의 경우 호출시마다 타입을 명시적으로 지정해야한다.

이 경우, 해당 클래스마다 Generic 클래스가 구현되었는지 확인해야한다는 단점이 있다.


하지만 일반 클래스내에서 오버로딩을 통해 Generic 함수를 구현하면

캐스팅을 통한 문제(박싱/언박싱 오버헤드, 런타임 에러)가 발생하지 않는다.


아래 코드와 같이 Utils 클래스에 Generic 메서드를 구현할 수 있다.

public static class Utils
{
  public static T Max<T>(T left,T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left;
  public static T Min<T>(T left, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? left : right;
}

컬렉션의 경우 내부 원소를 T 타입으로 유지해야하기 때문에 Generic 클래스로 구현해야하는 것이 맞지만

필드가 타입에 따라 달라지지 않는 경우 Generic 클래스 대신 Generic 메서드를 작성하는 것을 고려한다.




🔸 ITEM 26. Generic 인터페이스와 Non-Generic 인터페이스를 함께 구현하라

새로운 라이브러리를 개발할 때에 Generic 타입뿐 아니라 고전적인 방식도 함께 지원하면 라이브러리의 활용도를 좀 더 높일 수 있다.

Generic이 아닌 방식도 지원하려면 다음 세가지에 대해서 Non-Generic 방식을 지원해야한다.

1 2 3
클래스와 인터페이스 public 속성 serialize 대상이 되는 요소


그럼 어떻게 Generic 방식에 Non-Generic 방식을 추가할까?

public class Name : IComparable<Name> 
{ 
    public string First { get; set; } 
    public string Last { get; set; } 
    public string Middle { get; set; } 
    
    // IComparable<Name> 멤버 
    public int CompareTo(Name other) 
    { 
        if (Object.ReferenceEquals(this, other)) 
        { 
            return 0; 
        } 
        if (Object.ReferenceEquals(this, null)) 
        { 
            return 1; // null이 아닌 객체는 null보다 크다. 
        } 
        
        int rVal = Comparer<string>.Default.Compare(Last, other.Last); 
        if (rVal != 0) 
        {
            return rVal; 
        }
        
        rVal = Comparer<string>.Default.Compare(First, other.First); 
        if (rVal != 0) 
        {
            return rVal; 
        }
        return Comparer<string>.Default.Compare(Middle, other.Middle); 
    } 
}

Name이라는 클래스에 객체의 선후 관계를 정의하는 IComparable<T>의 인터페이스를 적용한 코드이다.

최근 코드만을 지원하다면 이대로도 충분하겠지만 이번 파트는 Generic 이전에 개발된 코드들에게도 지원이 되도록 코드를 짤 생각이다.


따라서 IComparable 인터페이스도 추가해볼 것이다.

public class Name : IComparable<Name>, IComparable { 
    public string First { get; set; } 
    public string Last { get; set; } 
    public string Middle { get; set; } 
    
    // IComparable<Name> 멤버 => 생략 
    public int CompareTo(Name other) { ... } 
    
    // IComparable 멤버 
    int IComparable.CompareTo(object obj) 
    { 
        if (obj.GetType() != typeof(Name)) 
        {
            throw new ArgumentException("Argument is not a Name object"); 
        }
        return this.CompareTo(obj as Name); 
    } 
}

위 코드에서 IComparable.CompareTo(object obj)라고 명시적으로 인터페이스를 구현하였음에 주목해보자.

이렇게 하면 실수로 Generic 인터페이스 대신 Non-Generic 인터페이스를 사용하는 것을 방지할 수 있다.

이런 식으로 코드를 작성하면 Non-Generic 타입의 인터페이스에 포함된 메서드를 호출하기 위한 유일한 방법은

명시적으로 IComparable 인터페이스 참조를 통해서 메서드를 호출하는 경우 뿐이다.




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

인터페이스에서 정의하는 멤버들은 이를 구현하는 클래스에서 반드시 구현해야한다.

반드시 구현해야하는 멤버의 수는 최소한으로 하기 위해 노력하고, 확장 메서드를 통해 다양한 기능을 제공하는 것이 좋다.

추가로 확장 메서드를 이용하면 인터페이스에서 정의된 메서드의 기본 구현체를 제공해줄 수도 있다.

// IFoo 인터페이스
public interface IFoo
{
    int Marker { get; set; }
}

// IFoo 의 확장 메서드
public static class FooExtenstions
{
    public static void NextMarker(this IFoo thing) =>
        thing.Marker += 1;
}

// IFoo의 구현체 MyType 클래스
public class MyType : IFoo
{
    public int Marker { get; set; }
}

// MyType 클래스에는 NextMarker() 메서드가 정의되어 있지 않지만 호출할 수 있다.
MyType t = new MyType();
t.Marker = 1;
t.NextMarker();




🔸 ITEM 28. 확장 메서드를 이용하여 구체화된 Generic 타입을 개선하라.

ITEM 27과 비슷하게 List, Dictionary<EmployeeID, Employee>와 같이

이미 구체화된 컬렉션 타입에 확장 메서드를 추가하여 새로운 기능을 추가할 수 있다.

예시로, System.Linq.Enumerable 클래스는 특정 IEnumerable 타입에 대한 확장 메서드들이 정의되어 있다.

public static class Enumerable
{
    public static int Average(this IEnumerable<int> sequnece);
    public static int Max(this IEnumerable<int> sequence);
    public static int Min(this IEnumerable<int> sequence);
    public static int Sum(this IEnumerable<int> sequence);

    // 다른 메서드 생략
}

🔹 확장 메서드를 사용했을 떄의 장점

  1. 단순한 기능을제공하는 메서드를 다양하게 재사용할 수 있다.

  2. 컬렉션 고유의 저장소 모델과 무관하게 기능을 구현할 수 있다. (IEnumerable` 등 사용)