C#/Advanced C#

[Effective C#] 3장 제너릭 활용

로파이 2021. 11. 20. 13:31

C# 제너릭 타입

.NET 런타임이 제너릭 타입을 JIT 컴파일할 때 타입 매개변수에 값 타입이 지정되면 두 단계를 거친다.

 

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

2) 대체된 타입을 이용하여 실제 기계어 코드를 만들어낸다.

 

어셈블리가 로드되는 시점이 아니라 로드된 타입의 특정 메서드가 최초로 호출되는 시점에 호출 메서드에 대해서만 JIT 컴파일이 이루어지고 메서드 IL 코드가 기계어 코드로 대체된다.

 

아이템 18. 반드시 필요한 제약 조건만 설정하라.

 

제너릭 타입 T에 대한 조건을 설정하여 타입을 제한할 수 있다. 타입을 제한하지 않은 경우 가장 기본적인 System.Object의 기능만을 사용하게 된다. 타입을 설정하여 해당 제너릭 타입이 사용할 수 있는 기능을 메서드 내에서 참조할 수 있도록 할 수 있다.

 

컴파일러 입장에서 좋은 점

- 제너릭 타입을 작성할 때 도움이 된다. 해당 타입이 제공하는 기능을 모두 제공한다고 가정하고 사용할 수 있게 된다.

- 타입 매개변수로 올바른 타입을 지정했는지를 컴파일 타임에 확인할 수 있다.

public static bool AreEqual<T>(T left, T right)
    where T : IComparable<T> => left.CompareTo(right) == 0;

 - T를 ICompareable<T> 인터페이스 구현체로 제한하였기 때문에 제너릭 메서드에서 CompareTo를 호출할 수 있게된다.

public static bool AreEqual2<T>(T left, T right) => left.Equals(right);

- 타입을 설정하지 않으면 System.Object 메서드를 호출한다. (Object.Equals(Object))

 

타입을 과도하게 설정하지 않기

- default와 new() 제한자

new 제한자를 사용하는 대신 메서드내에서 default() 연산자를 사용하는 것이 낫다면 new 제한자를 사용안해도 될 수 있다.

default는 값 타입에 대하여 기본 값 0을 사용하고 참조 타입에서 null을 가져온다. new()는 참조 타입에 대하여 새로 만든 인스턴스를 사용할 때 사용한다.

 

- 제너릭 컬렉션에서 특정 조건을 만족하는 원소 반환하기

public static T FirstOrDefault<T>(this IEnumerable<T> sequence, Predicate<T> test)
{
  foreach(T value in sequence)
  {
    if (test(value))
    	return value;
  }
  return default(T);
}

결과 원소는 default로 0으로 초기화된 값 타입이거나 T가 참조 타입인 경우 null을 반환한다.

 

- new 제한자를 사용하는 경우

 public static T Factory<T>(FactoryFunc<T> makeANewT) where T : new()
 {
   	T rVal = makeANewT();
   	if (rVal == null)
   		return new T();
   	else
   		return rVal;
 }

T는 new() 제한이 되어 있지만 값이나 참조타입 모두 Factory<T> 함수를 사용할 수 있다.

JIT 컴파일러가 값 타입에 대해 널 체크를 할 수 없으므로 if (rVall == null) 검사 코드를 제거한다.

 

아이템 19. 런타임에 타입을 확인하여 최적의 알고리즘을 사용하라

 

역순회 방식을 제공하는 ReverseEnumerater와 ReverseEnumerable을 설계한다고 가정하자

  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;
            }
        }

ReverseEnumerable<T>는 IEnumerable<T>를 구현하므로 GetEnumerator()를 구현해야한다.

ReverseEnumerator<T>는 IEnumerator<T>와 IDisposable를 구현하므로 Current 프로퍼티와 Dispose() / MoveNext() / Reset()을 구현해야한다.

 

ReverseEnumerable 생성자 다듬기

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

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

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

ReverseEnumerable이 GetEnumerator를 호출할 때 origianlSequence를 새로 생성해야했는데 만약 기존 입력 IEnumerable<T>가 IList<T>인 것이 확인되면 새로 생성 비용없이 그대로 사용할 수 있을 것이며 아닐 경우에만 새로 생성하게 된다.

 

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

 

IComparable 인터페이스를 구현하면 해당 클래스 인스턴스간의 선후 관계를 알 수 있다. 해당 선후 관계는 해당 클래스를 사용하는 컬렉션을 정렬할 때 사용하게 된다.

        public struct Customer : IComparable<Customer>, IComparable
        {
            private readonly string name;

            public Customer(string name)
            {
                this.name = 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);
            }
        }

IComparable<T>를 구현하였다면 IComparable를 같이 구현하는 것이 좋다.

 

해당 클래스는 비교가능을 내포하므로 관계 연산자를 오버로딩하는 것도 고려할 만하다.

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;

선후 관계 연산을 대신하는 대리자가 존재하는데, int Comparison<T>는 제너릭 타입 T에 대해 두 인스턴스를 비교하여 int를 반환하는 대리자이다.

public delegate int Comparison<in T>(T x, T y);

고객 Customer의 수익 revenue를 기준으로 정렬을 하는 대리자 인스턴스를 만들 수 있다.

public static Comparison<Customer> CompareByRevenue => (left, right) => left.revenue.CompareTo(right.revenue);

혹은 IComparer<T> 인터페이스를 구현한 클래스를 인스턴스로써 정렬 도구로 사용할 수 있다.

// 매출을 기반으로 Customer 객체를 비교하는 클래스
// 이 클래스는 항상 인터페이스 참조를 통해서 사용된다.
private class RevenueComparer : IComparer<Customer>
{
	int IComparer<Customer>.Compare(Customer left, Customer right)
		=> left.revenue.CompareTo(right.revenue);
}

 

아이템 21. 타입 매개변수가 IDisposable을 구현한 경우르 대비하여 제너릭 클래스를 작성하여라

public interface IEngine
{
	void DoWork();
}

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

IEngine 인터페이스를 갖는 T가 IDisposable을 구현한 경우 비관리 리소스가 누수될 위험이 있다. 따라서 제너릭 타입의 IDisposable 인터페이스를 구현한 경우를 대비하도록 한다.

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

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

 

아이템 22. 공변성과 반공변성을 지원하라

공변성과 반공변성 : https://narakit.tistory.com/215

C#의 제너릭 인터페이스와 대리자에 사용되는 공변성/반공변성은 제너릭 타입 T에 대한 캐스팅을 제공한다.

 

- 공변성

주로 반환 타입에 지정되며 out 키워드를 지정하여 해당 제너릭 타입이 공변성만 지원하도록 제한한다. 따라서 반환되는 타입은 오직 공변성에 따라 타입 변환하여 사용할 수 있다.

 

ex) IEnumerable<out T> Func<out TResult> 대리자

public interface IEnumerable<out T> : IEnumerable
{
	IEnumerator<T> GetEnumerator();
}
public delegate TResult Func<out TResult>();

  

공변성을 지원하는 타입은 일반 다형성 클래스의 참조 기준과 같이 파생 타입을 기반 타입으로 지칭할 수 있다.

IEnumerable<Derived> d = new List<Derived>();
IEnumerable<Base> b = d;

 

- 반공변성

주로 입력 매개변수 타입에 지정되며 in 키워드를 사용하여 해당 제너릭 타입이 반공변성만 지원하도록 제한한다. 따라서 반환되는 타입은 오직 반공변성에 따라 타입 변환하여 사용할 수 있다.

 

ex) Action<T>와 입력 매개변수가 있는 Func<T, TResult>대리자

public delegate void Action<in T>(T obj);

public delegate TResult Func<in T, out TResult>(T arg);

 

반공변성을 지원하는 타입은 입력 매개변수에 대해 기반 클래스로 캐스팅을 안전하게 할 수 있으므로 파생 클래스 입력 매개변수를 가지는 대리자를 통해 호출할 수 있다.

Action<Base> b = (target) => { Console.WriteLine(target.GetType().Name); };
Action<Derived> d = b;
d(new Derived());

 

공변성과 반공변성은 참조 타입에만 적용되므로 값 타입을 사용하였다면 해당 타입은 불변성(공변도 아니고 반공변도 아닌)을 가진다. 따라서 위와 같은 두 형식 변환 지원을 사용할 수 없다.

 

아이템 23. 타입 매개변수에 대해 메서드 제약 조건을 설정하라면 델리게이트틀 활용하라

 

타입 제약 조건은 struct,class,new 등을 설정할 수 있으나 정적 메서드를 반드시 구현해야하거나 매개변수가 있는 생성자를 요구하는 제약 조건을 설정할 수 없다. 우회적인 방법으로는 인터페이스를 사용하여 제약하는 것이다.

 

임의의 타입 T에 대해 두 객체를 더하는 Add 메서드를 반드시 제공해야한다면, Func<T, T, T>와 같은 델리게이트를 이용해서 제약할 수 있다.

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

static void Main(string[] args)
{
    int a = 6;
    int b = 7;
    int sum = Example.Add(a, b, (x, y) => x + y);
}

Example.Add의 호출에서 람다 표현식으로 델리게이트를 전달하였다. 해당 표현식은 컴파일러에 의해 private 정적 메서드로 변환되고 명명되어 사용되어진다.

 

매개변수를 갖는 생성자를 갖도록 제약하기

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 타입의 매개변수 두 개를 요구하는 생성자를 가진다.

 

비슷하게 Func 대리자를 이용하여 Point를 생성할 때 해당 매개변수 생성자를 사용할 수 있도록 제약할 수 있다. 다음 Zip 함수는 Func<T, T, TResult> 대리자를 이용하고 실제 Point를 생성하는 (x,y) => new Point(x,y) 람다 표현식을 전달한다.

    	public static class Utilities
        {
            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 };

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

 

제너릭 메서드에서 델리게이트를 필요로하는 상황에서 이러한 방식을 사용할 수 있다. 여러 제너릭 메서드가 한 클래스에서 동일한 델리게이트를 사용한다면, 해당 클래스 내에 델리게이트를 멤버로 두고 생성할때 여러 메서드에서 공통적으로 사용할 해당 델리게이트를 전달하여 생성할 수 있다.

 

이제 스트림에서 객체 데이터를 읽어 생성할 수 있는 메서드를 사용할 것이다. 그러한 대리자를 정의해준다.

public delegate T CreateFromStream<T>(TextReader reader);

이제 InputCollection은 해당 대리자를 사용해서 T 타입의 원소를 읽을 것 이다. 생성자에서 해당 대리자 인스턴스를 전달받아 멤버로 초기화한다.

        public class InputCollection<T>
        {
            private List<T> thingsRead = new List<T>();
            private readonly CreateFromStream<T> readFunc;

            public InputCollection(CreateFromStream<T> readFunc)
            {
                this.readFunc = readFunc;
            }

            public void ReadFromStream(TextReader reader) => thingsRead.Add(readFunc(reader));

            public IEnumerable<T> Values => thingsRead; 
        }

InputCollection<T>는 생성자에서 전달받은 대리자 인스턴스(readFunc)를 사용해서 실제 데이터를 로드하는 메서드 ReadFromStream에서 해당 대리자를 사용한다.

 

Point를 원소로 갖는 InputCollection<Point>를 이용하는 경우 Point 클래스내에 Stream을 이용하여 생성하는 생성자가 오버로딩 되어 있어야하며 다음과 같이 Stream을 이용한 생성자 형태의 람다를 만들어 InputCollection을 사용한다.

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

            public Point(System.IO.TextReader reader)
            {
                //.. 생략
            }
        }
        static void Main(string[] args)
        {
            var readValues = new InputCollection<Point>((stream) => new Point(stream));
        }

 

아이템 24. 베이스 클래스나 인터페이스에 대해서 제너릭을 특화하지 말라

 

제너릭 클래스나 메서드를 작성할 때 컴파일러가 오버로드된 메서드가 여러개일 경우 어떤 메서드를 우선적으로 선택하는지 이해하고 있어야한다.

 

MyBase, MyDerived, IMessageWriter 인터페이스

public class MyBase
{ }

public interface IMessageWriter
{
	void WriteMessage();
}

public class MyDerived : MyBase, IMessageWriter
{
	void IMessageWriter.WriteMessage() => WriteLine("Inside Derived.WriteMessage, Interface");
	public void WriteMessage() => WriteLine("Inside Derived.WriteMessage, MyDerived");
}

MyDerived 클래스는 MyBase를 상속하면서 IMessageWriter의 인터페이스를 구현한다.

IMessageWriter의 WriteMessage()를 직접 구현한 것과 MyDerived의 고유 메서드로써 WriteMessage()를 구현한 것이 특징이다.

static void WriteMessage<T>(T obj) where T : IMessageWriter
{
    Write("Inside WriteMessage<T>(T): ");
    obj.WriteMessage();
}

static void WriteMessage(IMessageWriter obj)
{
    Write("Inside WriteMessage(IMessageWrite): ");
    obj.WriteMessage();
}

위 두 함수가 있다하면, 다음 호출은 서로 다른 함수를 선택하게 된다.

MyDerived d = new MyDerived();
WriteMessage(d); // static void WriteMessage<T>(T obj)
WriteMessage((IMessageWriter)d); // static void WriteMessage(IMessageWriter obj)

각 오버로딩된 메서드에서 가장 적합한 인자를 선택하게 되고 MyDerived 타입은 제너릭 메서드 T를 대체하여 WriteMessage<T>를 호출하고 IMessageWriter로 캐스팅된 타입은 아래 함수와 일치하기 때문에 아래 함수를 호출한다.

 

또한 WriteMessage<T>는 T가 IMessageWriter로 한정시켰기 때문에 WriteMessage()를 호출할 수 있게 된다. 이 때, obj는 MyDerived 타입이지만 인터페이스를 직접 구현한 void IMessageWriter.WriteMessage()가 메서드가 호출된다.

 

제너릭 메서드 내에서 호출하는 것이 아닌 MyDerived 인스턴스를 통해 WriteMessage()를 호출하면 직접 구현한 인터페이스 대신 MyDerived 클래스의 WriteMessage()가 호출된다.

MyDerived d = new MyDerived();
d.WriteMessage(); // MyDerived 클래스의 WriteMessage()가 호출

 

제너릭 메서드를 특화하면 어떤 함수가 호출되어 어떤 행동을 기대하기가 쉽지 않다. 정해진 방식이 있으나 제너릭 메서드를 이용하는 클래스를 사용할 때 주의해야하고 파생된 클래스를 만들때 해당 제너릭 메서드의 특화를 같이 구현해야한다.

 

아이템 25. 타입 매개변수로 인스턴스 필드를 만들 필요가 없다면 제너릭 메서드를 정의하라.

 

제너릭 클래스를 사용하지않고 제너릭 메서드를 사용해야하는 경우를 고려해야한다.

 

예시)

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 타입으로 유지해야하기 때문에 제너릭 클래스로 구현해야하는 것이 맞지만 필드가 타입에 따라 달라지지 않는 경우 제너릭 클래스 대신 제너릭 메서드를 작성하는 것을 고려한다.

 

아이템 26. 제너릭 인터페이스와 논제너릭 인터페이스를 함께 구현하라

 

Name이라는 클래스를 IComparable<Name>과 IEquatable<Name> 인터페이스를 상속해서 대소 및 동등 비교에 대한 기능을 지원하고자 한다.

    public class Name : IComparable<Name>, IEquatable<Name>
    {
        public string First { get; set; }
        public string Last { get; set; }
        public string Middle { get; set; }

        public int CompareTo(Name other)
        {
            if (Object.ReferenceEquals(this, other))
                return 0;

            if (Object.ReferenceEquals(other, null))
                return 1;

            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);
        }

        public bool Equals(Name other)
        {
            if (Object.ReferenceEquals(this, other))
                return true;

            if (Object.ReferenceEquals(other, null))
                return false;

            return Last == other.Last && First == other.First && Middle == other.Middle;
        }
    }

 외부 라이브러리에서 해당 함수를 사용하고자 하였다하자

public static bool CheckEquality(object left, object right)
{
  	if (left == null)
  		return right == null;

	return left.Equals(right);
}

Name 인스턴스 둘을 left와 right에 대입하였다면 left.Equals는 Name 클래스의 Equals를 호출하는 것이 아니라 object.Equals를 호출하게 된다. 다음과 같이 제너릭 메서드와 인터페이스 한정자를 이용하면 IEquatable<T>의 Equals를 구현한 메서드를 호출하도록 할 수 있다.

public static bool CheckEquality<T>(T left, T right) where T : IEquatable<T>
{
  	if (left == null)
    		return right == null;

	return left.Equals(right);
}

위 제너릭 메서드는 Equatable<T>를 구현한 클래스만 사용하기 때문에 범용적인 메서드로 사용할 수 없다.

 

따라서 일반적으로는 Equatable<T>를 구현한 클래스에서 bool Object.Equals(object obj)를 재정의해주는 것이 좋다.

public override bool Equals(object obj)
{
  if (obj.GetType() == typeof(Name))
  	return this.Equals(obj as Name);
  else
  	return false;
}

Equals를 재정의하였다면, GetHashCode()도 재정의해야한다.

public override int GetHashCode()
{
  int hashCode = 0;

  if (Last != null)
 	 hashCode ^= Last.GetHashCode();

  if (First != null)
  	hashCode ^= First.GetHashCode();

  if (Middle != null)
  	hashCode ^= Middle.GetHashCode();

  return hashCode;
}

IEquatable을 정의하였다면, ==와 != 표준 연산자를 지원하도록 오버로딩하는 것이 좋다.

public static bool operator==(Name left, Name right)
{
	if (left == null)
    	return right == null;
    
    return left.Equals(right);
}

public static bool operator!=(Name left, Name right)
{
	if(left == null)
    	return right == null;
	
    return !left.Equals(right);
}

 

IComparable<T>에 대해서도 IComparable의 명시적인 인터페이스 구현 방식을 사용하여 IComparable 멤버를 구현한다.

명시적 구현 방식은 실수로 제너릭 인터페이스 대신 논제너릭 인터페이스가 선택되는 것을 방지한다.

public class Name : IComparable<Name>, IEquatable<Name>, IComparable
{
    // 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<T>를 구현하였다면, <,>,<=,>=와 같은 표준 연산자를 지원하도록 오버로딩하는 것이 좋다.

 

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

 

인터페이스는 가장 기본적인 기능을 담당하는 메서드만 정의하고 필요할 때마다 확장 메서드를 제공하는 식으로 인터페이스의 기능을 확장시키도록한다.

 

아이템 28. 확장 메서드를 이용하여 구체회된 제너리 타입을 개선하라

 

어떤 기능을 추가하기위해 새로운 타입의 클래스를 상속 방식으로 정의하지말고 확장 메서드를 이용하여 기능을 확장하는 식으로 개선하는 것이 좋다.

 

Customer 원소를 갖는 컬렉션에서 모든 고객에게 쿠폰을 보내거나 (SendEmailCoupons) 지난 한 달간 아무런 주문도 하지 않은 고객을 찾는 메서드는 다음과 같이 작성할 수 있다.

 public static class CustomerExtension
 {
   public static void SendEmailCoupons(this IEnumerable<Customer> customers, Coupon specialOffer)
   {
   }
   public static IEnumerable<Customer> LostProspects(this IEnumerable<Customer> targetList)
   {
   }
 }

// 클래스 상속을 이용
public class CustomerList : List<Customer>
{
  public void SendEmailCoupons(Coupon specialOffer);
  public static IEnumerable<Customer> LostPospects();
}
  • 클래스 상속을 이용한 경우 확장 메서드에서 IEnumerable<T>의 이터레이터 메서드(yield return)를 사용할 수 없는 단점이 있다.
  • 제너릭 타입의 컬렉션을 상속하는 것은 대체로 좋지 않은 선택이다.

 

출처 : Effective C# 빌 와그너 저 - 한빛 미디어