# Effective 3장 ## 각 장별 요약 - 1장 : C# 언어 요소 - 2장 : .NET 리소스 관리 - 3장 : 제네릭 활용 - 4장 : LINQ 활용 - 5장 : 예외 처리 --- ## 3 장 : 제네릭 활용 > 제네릭을 이용한 다양한 활용에 대해 학습한다. ### 아이템 18 : 반드시 필요한 제약 조건만 설정하라 - 타입 매개변수에 대한 제약조건은 클래스가 작업을 올바르게 수행하기 위해서 타입 매개변수로 전달할 수 있는 타입의 유형을 제한하는 방법이다. - 해당 제약 조건을 선언하면 제약 형식의 작업 및 메서드 호출을 사용할 수 있다. - 제네릭 클래스 또는 메서드가 단순 할당 또는 System.Object에서 지원하지 않는 메서드 호출 이외의 작업을 제네릭 멤버에서 사용하는 경우 형식 매개 변수에 제약 조건을 적용한다. - 제약조건을 설정하지 않으면 런타임에 더 많은 검사를 수행할 수 밖에 없다. - 그러나 필요한 만큼만 제약조건을 설정했다고 생각하겠지만, 그마저도 과도할 수 있다는 것을 항상 염두에 둬야 한다. - 제약조건을 과도하게 설정하는 것 또한 좋지 않다. - 적당히 제약조건을 설정하면 다음과 같은 상황에서 도움이 될 수 있다. ```csharp= // # 1 public static bool AreEqual<T>(T left, T right) { if (left == null) return right == null; if (left is IComparable<T>) { IComparable<T> Ival = left as IComparable<T>; if (right is IComparable<T>) return Ival.CompareTo(right) == 0; else throw new ArgumentException("Type dose not implement IComparable<T>", nameof(right)); } else { throw new ArgumentException("Type dose not implement IComparable<T>", nameof(left)); } } // #2 public static bool AreEqual2<T>(T left, T right) where T : IComparable<T> => left.CompareTo(right) == 0; ``` - `# 1` , `# 2` 메소드 모두 런타임 오류를 방지할 수 있는 `AreEqual` 메소드이다. - 하지만 1번의 경우 코드를 통해 이를 방지했고, 2번의 경우 컴파일러를 통해 방지했다. - 이와같이 제약조건을 적당히 사용하면 코드도 더 간결해지고, 컴파일러를 통해 코딩상의 실수를 알려줄 수 도 있다. --- ### 아이템 19 : 런타임에 타입을 확인하여 최적을 알고리즘을 사용하라 - 제네릭 타입의 경우 타입 매개변수에 새로운 타입을 지정하여 손쉽게 재사용할 수 있다. - 또한 제네릭을 활용하면 코드를 덜 작성해도 되기 때문에 매우 유용하다. - 타입 매개변수에 새로운 타입을 지정한다는 것은 유사한 기능을 가진 새로운 타입을 생성한다는 것을 의미한다. - 하지만 타입이나 메서드를 제네릭화 하면 구체적인 타입이 주는 장점을 잃고 타입의 세부적인 특성까지 고려하여 최적화한 알고리즘을 사용할 수 없게 된다. - 제네릭의 인스턴스화는 런타임의 타입을 고려하지 않으며 컴파일타임의 타입만을 고려한다. - 어떤 알고리즘이 특정 타입에 대해 더 효율적으로 동작한다고 생각된다면 해당 타입을 이용하도록 코드를 작성하라. - 효율적인 코드를 작성하려면 이러한 사실을 반드시 알고 있어야 한다. --- ### 아이템 20 : `IComparable<T>` 와 `IComparer<T>` 를 이용하여 객체의 선후 관계를 정의하라 - 컬렉션을 정렬하거나 검색하려면 타입 내에 객체의 선후 관계를 판단할 수 있는 기능을 정의해야한다. - .NET Framework 에서는 객체의 선후 관계를 정의하기 위해서 `IComparable<T>` 와 `IComparer<T>` 2개의 인터페이스를 제공한다. - `IComparable<T>` 는 타입의 기본적인 선후관계를 정의하는 인터페이스이고 `IComparer<T>` 를 이용하면 기본적인 선후 관계 이외에 추가적인 선후 관계를 정의할 수 있다. - 또한 타입 내에 관계 연산자를 재정의하면 해당 타입에 최적화된 방식으로 객체의 선후 관계를 판단할 수 있다. - `IComparable<T>` 인터페이스는 `CompareTo()` 메소드만을 담고있고 이는 C언어의 `strcmp` 와 같은 동작을 한다. - 비교 대상이 작으면 0 보다 작은값, 같으면 0, 크면 0보다 큰 값을 반환한다. - `IComparable<T>` 를 구현할 때는 하위호환성을 고려하여 `IComparable` 도 함께 구현해야 한다. - `IComparable` 은 매 호출마다 박싱 / 언박싱이 필요하므로 비 효율적이다. - ex ) 1000개의 항목을 정렬하기 위해서 2만번 이상의 박싱 / 언박싱이 필요하다. --- ### 아이템 21 : 타입 매개변수가 `IDisposable` 을 구현한 경우를 대비하여 제네릭 클래스를 작성하라 - 제약 조건은 두 가지 역할을 한다. 1. 런타임 오류가 발생할 가능성이 있는 부분을 컴파일 타임 오류로 대체할 수 있다. 2. 타입 매개변수로 사용할 수 있는 타입을 명확히 규정하여 사용자에게도 도움을 준다. - 하지만 제약 조건은 타입 매개변수가 무엇을 해야 하는지만을 규정할 수 있고, 무엇을 해서는 안되는지를 정의할 수 없다. - 대부분의 경우 타입 매개변수로 지정하는 타입이 제약조건을 통해 요구하는 작업 외에 다른 작업을 추가로 수행할 수 있는지에 대해서 신경쓰지 않는다. - 하지만 타입 매개변수로 지정하는 타입이 `IDisposable`을 구현하고 있다면 특별한 추가 작업이 반드시 필요하다 ```csharp= public interface IEngine { void DoWork(); } public class EngineDriverOne<T> where T : IEngine, new() { public void GetTHingsDone() { T driver = new T(); driver.DoWork(); } } ``` - `<T>` 가 `IDisposable` 을 구현한 타입일 경우 리소스 누수가 발생할 수 있다. - 따라서 `<T>` 타입으로 지역변수를 생성할 때마다 `<T>` 가 `IDisposable` 을 구현하고 있는지 확인해야 하며, 만약 `IDisposable` 을 구현하고 있다면 추가적인 처리를 해야한다. ```csharp= public void GetTHingsDone() { T driver = new T(); using (driver as IDisposable) { driver.DoWork(); } } ``` - 이처럼 코드를 작성하면 컴파일러는 `IDisposable` 로 형변환된 객체를 저장하기 위해서 숨겨진 지역변수를 생성한다. - 만약 `<T>` 가 `IDisposable` 을 구현하지 않았다면 이 지역변수의 값은 `null` 이 된다. - C# 컴파일러는 이 지역변수의 값이 `null` 인 경우 `Dispose()` 가 호출되지 않는다. - 반대로 `<T>` 가 `IDisposable` 을 구현했다면 `using` 블록을 종료할 때 `Dispose()` 메서드가 호출된다. --- - 타입 매개변수로전달한 타입을 이용하여 멤버 변수를 선언한 경우에는 이보다 조금 더 복잡하다. - `IDisposable` 을 구현했을 가능성이 있는 타입으로 멤버 변수를 선언한 것이기 때문이다. - 이 경우 제네릭 클래스에서 `IDisposable` 을 구현하여 해당 리소스를 처리해야 한다. ```csharp= public sealed class EngineDriverOne<T> : IDisposable where T : IEngine, new() { private Lazy<T> driver = new Lazy<T>(() => new T()); public void GetTHingsDone() => driver.Value.DoWork(); public void Dispose() { if (driver.IsValueCreated) { var resource = driver.Value as IDisposable; resource?.Dispose(); } } } ``` - 하지만 제네릭 클래스의 인터페이스를 조금 변경하면 이런 복잡한 설계를 피할 수 있다 ex ) `Dispose` 의 호출 책임 / 객체의 소유권을 제네릭 클래스 외부로 옮기기 등 --- ### 아이템 22 : 공변성과 반공변성을 지원하라 - 타입의 가변성, 즉 공변과 반공변은 특정 타입의 객체를 다른 타입의 객체로 변환할 수 있는 성격을 일걷는다. - 이러한 변환을 지원하려면 제네릭 인터페이스나 델리게이트의 정의 부분에 제네릭 공변 / 반공변을 지원한다는 의미의 데코레이터를 추가해야한다. - 가변성의 반대는 불변성이라고 한다. > 공변성 : 자신과 자식 타입으로만 형변환. out 키워드 > 반공변성 : 자신과 부모 타입으로만 형변환. in 키워드 - 타입 매개변수로 주어지는 타입들이 상호 호환 가능할 경우 이를 이용하는 제네릭 타입도 호환 가능함을 추론하는 기능이다. - `X` 를 `Y` 로 바꾸어 사용할 수 있는 경우 `C<X>` 를 `C<Y>` 로도 바꿔 사용할 수 있다면, `C<T>` 는 공변이다. - `Y` 를 `X` 로 바꾸어 사용할 수 있는 경우 `C<X>` 를 `C<Y>` 로도 바꿔 사용할 수 있다면, `C<T>` 는 반공변이다. ex ) `IEnumerable<Object>` 타입의 매개변수를 취하는 메서드는 `IEnumerable<MyDerivedType>` 타입 객체도 받아들일 수 있어야 하고, `IEnumerable<MyDerivedType>` 타입의 객체를 반환하는 메서드의 결과값이 `IEnumerable<Object>` 타입 객체에 할당될 수 있어야한다. - C# 4.0 이전에는 제네릭 타입이 이러한 가변성을 지원하지 않았다. - 그 이후 in 과 out 키워드가 추가됐는데 이를 이용하면 제네릭을 좀 더 유용하게 사용할 수 있다. - 이 데코레이터는 제네릭 인터페이스와 델리게이트 선언시에 사용할 수 있다. - .NET Base Class Library ( BCL ) 에 포함된 델리게이트의 정의도 가변성을 지원한다. ```csharp= public delegate TResult Func<out TResult>(); public delegate TResult Func<in T, out TResult>(T arg); public delegate void Action<in T>(T arg); public delegate void Action<in T1, in T2>(T1 arg1, T2 arg2); ``` --- ### 아이템 23 : 타입 매개변수에 대해 메서드 제약조건을 설정하려면 델리게이트를 활용하라 - C# 에서 제약조건을 설정하는 방법은 한계가 많은 것 처럼 보인다. 1. 베이스 클래스의 타입 2. 특정 인터페이스 3. class 타입 4. struct 타입 5. 생성자의 매개변수 유무 - 위의 다섯가지 조건 정도만을 설정할 수 있기 때문이다. - 임의의 정적 메서드를 반드시 구현해야 한다거나, 매개변수를 취하는 여타의 생성자를 반드시 구현하도록 제약조건을 설정할 수 는 없다. - 아래와 같이 제한적이나마 인터페이스를 선언 / 상속하여 다양한 요건을 만족하도록 설정이 가능하긴 하다. ```csharp= // 인터페이스 선언 public interface IAdd<T> { public T Add(T arg1, T arg2); } // 인터페이스 내부 메소드 구현 강제 class Program : IAdd<int> { public int Add(int a, int b) { return a + b; } public static void Main() { Program MyProgram = new Program(); Console.WriteLine(MyProgram.Add(1, 2)); } } ``` - 함수를 강제하긴 했지만 인터페이스 상속과 더불어 닫힌 제네릭 설정, 그에 맞는 메소드 구현 등 사용자가 해야할 일이 많아진다. - 하지만 델리게이트를 사용한다면 사용자가 원하는 기능을 람다로 바로 적용할 수 있다. ```csharp= // 델리게이트 선언 public class DelegateAdd { public static T Add<T>(T left, T right, Func<T, T, T> AddDelegate) => AddDelegate(left, right); } class Program { public static void Main() { Program MyProgram = new Program(); // 람다로 바로 원하는 작업 수행 Console.WriteLine(DelegateAdd.Add(1, 2, (x, y) => x + y)); } } ``` --- ### 아이템 24 : 베이스 클래스나 인터페이스에 대해서 제네릭을 특화하지 말라 - 제네릭 메서드가 등장함에 따라 여러개의 오버로드된 메서드가 있는 경우, 이 중 하나를 선택하는 과정이 꽤 복잡해졌다. - 컴파일러는 제네릭 메서드의 타입 매개변수가 다른 타입으로 다양하게 변경될 수 있음을 고려하여 오버로드된 메서드 중 하나를 선택한다. - 그런데 자칫 이러한 동작 방식을 간과하게 되면 응용프로그램이 이상하게 동작할 수도 있다. - 제네릭 클래스나 제네릭 메서드를 작성할 때는 사용자가 가능한 한 안전하고 혼돈스럽지 않도록 작성해야 한다. - 특히 오버로드된 메서드가 여러개인 경우 컴파일러가 이중 하나를 어떻게 선택하는지 정확히 알고 있어야 한다. <details> <summary>예제</summary> <div> ```csharp= public class MyBase { } public interface IMessageWriter { void WriteMessage(); } public class MyDerived : MyBase, IMessageWriter { public void WriteMessage() => WriteLine("Inside MyDerived.WriteMessage"); } public class AnotherType : IMessageWriter { public void WriteMessage() => WriteLine("Inside AnotherType.WriteMessage"); } class Program { static void WriteMessage(MyBase b) { WriteLine("Inside WriteMessage(MyBase)"); } static void WriteMessage<T>(T obj) { Write("Inside WriteMessage<T>(T): "); WriteLine(obj.ToString()); } static void WriteMessage(IMessageWriter obj) { Write("Inside WriteMessage(IMessageWriter): "); obj.WriteMessage(); } public static void Main() { MyDerived d = new MyDerived(); WriteMessage(d); WriteLine(); WriteMessage((IMessageWriter)d); WriteLine(); WriteMessage((MyBase)d); WriteLine(); AnotherType anObject = new AnotherType(); WriteMessage(anObject); WriteLine(); WriteMessage((IMessageWriter)anObject); WriteLine(); } } /* 결과 Inside WriteMessage<T>(T): ConsoleApp1.MyDerived - d는 MyBase 보다 제네릭 T랑 더 가깝다 Inside WriteMessage(IMessageWriter): Inside MyDerived.WriteMessage Inside WriteMessage(MyBase) Inside WriteMessage<T>(T): ConsoleApp1.AnotherType Inside WriteMessage(IMessageWriter): Inside AnotherType.WriteMessage */ ``` </div> </details> - 사용자 명시적으로 타입을 설정한 일반 메서드 보다 제네릭 메서드가 우선적으로 선택되는 경우에 대해서도 명확히 이해하고 있어야 한다. - 따라서 베이스 클래스와 이로부터 파생된 클래스에 대해서 모든 메소드가 수행 가능하도록 하기 위해 제네릭을 특화하려는 시도는 바람직하지 않다. --- ### 아이템 25 : 타입 매개변수로 인스턴스 필드를 만들 필요가 없다면 제네릭 메서드를 정의하라 - 제네릭을 사용하다 보면 자칫 무작정 제네릭 클래스를 만드는 습관에 빠지곤 한다 - 하지만 유틸리티 성격의 클래스를 만드는 경우에는 일반 클래스 내에 제네릭 메서드를 작성하는 편이 훨씬 좋다. - 왜냐하면 제네릭 클래스를 작성하면 컴파일러의 입장에서는 전체 클래스에 대하여 타입 매개변수에 대한 제약 조건을 고려하여 컴파일을 해야하기 때문이다. - 반면 유틸리티 성격의 클래스를 만들 때 일반 클래스 내에 제네릭 메서드들을 배치하면 각 메서드별로 제약 조건을 달리 설정할 수 있다. - 이처럼 메서드별로 제약조건을 달리 설정하면 요청되는 메서드의 원형에 좀 더 정확히 부합하는 메서드를 생성 할 수 있으므로 사용자 입장에서도 메서드를 활용하기가 수월해진다. - 추가적으로 제네릭 메서드를 정의하면 타입 매개변수에 대한 제약 조건을 메서드 수준으로 지정할 수 있다. - 반면 제네릭 클래스를 정의하면 클래스 전체에 대항 제약 조건을 고려해야만 한다. - 이렇듯 제약조건의 적용 범위가 넓어지면 넓어질수록 코드를 수정하기가 점점 더 까다로워진다. - 둘 중 하나를 선택할 수 있는 상황이라면 제네릭 메서드를 이용하는 편이 낫다. - 정리하면 타입 매개변수로 인스턴스 필드를 만들어야 하는 경우에는 제네릭 클래스를 작성하고, 그렇지 않은 경우에는 제네릭 메서드를 작성하라. <details> <summary>예제</summary> <div> ```csharp= public static class Utils<T> { public static T Max(T lef, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left; public static T Min(T lef, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? left : right; } ``` - 위와같이 작성한 메서드는 잘 동작한다. - 하지만 숫자 타입에 사용가능한 Math.Min / Max 메서드를 사용할 수 없다는 단점이 있다. - 따라서 클래스를 일반 클래스로 정의하고 제네릭 메서드를 구현해보자 ```csharp= public static class Utils { public static T Max(T lef, T right) => Comparer<T>.Default.Compare(left, right) < 0 ? right : left; public static double Max(double left, double right) => Math.Max(left, right); ... } ``` - 위와 같이 작성한다면 타입별로 다른 메서드에서 최적의 효율을 낼 수 있다. - 또한 정적 메서드를 사용할 때 타입을 명시할 필요가 없어진다. </div> </details> --- ### 아이템 26 : 제네릭 인터페이스와 논제네릭 인터페이스를 함께 구현하라 - C# 에 제네릭이 포함되기 이전에 개발됐던 코드를 모조리 무시할 수 있다면 좋겠지만, 여러 이유로 이전 코드를 무시하기가 어렵다. - 새로운 라이브러리를 개발할 때 제네릭 타입 뿐 아니라 고전적인 방식도 함께 지원한다면 라이브러리 활용도를 좀 더 높일 수 있다. - 만약 제네릭 타입이 아닌 방식도 지원하겠다고 결정했다면 다음과 같은 대상들도 고려해야한다. 1. 클래스와 인터페이스 2. public 속성 3. 시리얼라이즈 대상이 되는 요소 - 위 세가지에 대해서 논제네릭 방식을 지원해야 한다. - 대부분의 경우 논제네릭 인터페이스를 추가하는 작업은 적절한 원형의 메서드를 추가하는 수준에서 간단히 해결된다. - Visual Studio 를 포함하여 다양한 도구들이 이같은 인터페이스를 손쉽게 구현할 수 있는 기능을 제공한다. - 이 기능을 이용하면 인터페이스 내에 정의된 메서드의 원형을 쉽게 추가할 수 있다. - 또한 논제네릭 인터페이스를 구현할 때는 반드시 명시적인 방법으로 구현하는 것이 좋다. --- ### 아이템 27 : 인터페이스는 간략히 정의하고 기능의 확장은 확장 메서드를 사용하라 > 확장 메서드 : 별도의 namespace를 가진 정적 메서드로 매개변수에 this 키워드를 추가하여 사용할 수 있다. 기존의 메서드에 기능을 추가 / 확장 할 수 있기 때문에 확장 메서드라고 불린다. - 확장 메서드를 이용하면 인터페이스에 새로운 동작을 추가할 수 있다. - 인터페이스에는 가능한 한 최소한의 기능만을 정의하고, 확장 메서드를 세트로 함께 구현하면 손쉽게 기능을 확장할 수 있다. - 특히 API를 추가적으로 정의하지 않고도 새로운 기능을 추가할 수 있다. - `System.Linq.Enumberable` 클래스가 이 기법을 활용한 대표적인 예다. - 확장 메서드를 사용하면 이미 해당 인터페이스나 메서드를 구현하고 있는 클래스를 수정할 필요가 없다. - ex) 이전에 `IEnumberable`을 구현하고 있는 클래스는 새로운 메서드를 구현해야할 필요가 없으므로 이전과 동일하게 `Geteunumerator` 만 구현하면된다. - 따라서 새로운 타입을 작성해야 한다고 생각이 든다면 우선 이 같은 확장 기법을 적용할 수 있을지 검토하자. - 새로운 인터페이스를 작성하는 경우에도 동일한 패턴을 사용할 수 있다. - 다양한 기능을 제공하도록 인터페이스를 정의하지 말고, 반드시 필요한 기능만을 포함하도록 간단히 인터페이스를 작성하자 - 그리고 사용자 편의를 위해 다양하게 제공하려는 기능은 확장 메서드 방식으로 작성하는 것이다. - 인터페이스 내에 다양한 기능을 정의하는 것에 비해 확장 메서드를 이용하는편이 필수로 작성해야 하는 메서드의 수도 줄일 수 있고 사용자에게 더 풍부한 기능을 제공할 수 있다. --- ### 아이템 28 : 확장 메서드를 이용하여 구체화된 제네릭 타입을 개선하라 - 응용프로그램을 개발하다보면 `List<int>`, `Dictionary<T>` 와 같이 제네릭 컬렉션에 타입 매개변수를 지정하여 사용할 때가 있다. - 이러한 컬렉션을 사용하는 이유는 특정 타입의 집합을 다루거나 컬렉션의 고유 기능 활용을 위함 일 것이다. - 기존에 사용중인 컬렉션 타입에 영향을 주지않으며 새로운 기능을 추가하고 싶다면, 구체화 된 컬렉션 타입에 대해 확장 메서드 작성이 도움이 된다. - 일례로 `Enumerable`에는 `IEnumberable<T>` 타입을 특정 타입으로 구체화 했을 때만 사용할 수있는 메서드들도 상당수 포함되어있다. ```csharp= public static class Enumerable { public static int Average(this IEnumberable<int> sequence); public static int Max(this IEnumberable<int> sequence); public static int Min(this IEnumberable<int> sequence); public static int Sum(this IEnumberable<int> sequence); ... } ``` - 이 패턴은 타입 매개변수로 특정 타입이 주어질 때, 해당 타입에 대하여 가장 효과적으로 동작하도록 코드를 분리하여 구현하는 방법이다. - 구체화된 제네릭 타입을 상속하여 메서드를 추가하기보다는 확장 메서드를 구현하는 편이 훨씬 낫다. - 게다가 컬렉션 고유의 저장소 모델과 무관하게 기능을 구현할 수있다. --- ### 느낀점 > 3장에서는 실제로도 많이 사용하는 제네릭에 대하여 알아보는 시간이었다. > > 모든 부분을 제네릭으로 치환하면 안좋은 점과 > 또 하위호환성을 고려해서 논제네릭 메서드등을 작성해야한다는 것, > 앞서 2장에서 배운 Dispose 패턴을 적용하여 > 멤버로 관리하는 제네릭 변수들도 관리 해줘야 하는 등 > 제네릭에 대한 장점과 단점, 또 이를 극복하는 방법을 알아보았다. > > 이론공부 외에도 실습이나 경험이 더 쌓여야 완전한 내 지식이 될 것 같아서 > 정리한 내용을 토대로 여러 테스트케이스와 구현을 해보면 좋을 것 같다.