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

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

개발자는 올바르게 작업을 수행하기 위한 최소한의 제약 조건만을 설정해야 한다.

너무 많은 제약 조건을 설정하면 이를 만족시키기 위해서 사용자들이 과도한 추가 작업을 수행해야 할수도 있다.

따라서 올바른 작업을 수행하기 위한 제약 조건의 수와 사용자가 이를 만족시키기 위해 추가로 수행해야 하는 작업의 양 사이에서 적절히 균형을 유지해야 한다.

어느 수준에서 균형을 맞출지는 타입의 유형에 따라 다를 수밖에 없지만 어떤 경우라도 극단적인 선택은 좋지 않다.

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

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

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

필요한 만큼만 제약 조건을 설정했다고 생각하겠지만 그마저도 과도할 수 있다는 것을 항상 염두에 둬야 한다.

우리 목표는 항상 중간 어디쯤을 찾아내는 것이다.

프로그래머로써 생각해야 하는 중요한 포인트라고 생각된다.

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

C# 컴파일러는 제네릭 타입에 대해 올바른 IL을 생성해야 할 책임이 있으며, 설사 컴파일러에게 타입 매개변수에 대한 충분한 정보가 제공되지 않는 경우에도 반드시 올바른 IL을 생성해야 한다.

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

컴파일러는 타입 매개변수에 대하여 사용자가 지정한 타입에 대하여 어떠한 가정도 할 수 없으므로 모든 타입이 System.Object로부터 파생된다는 사실만을 기반으로 이러한 결정을 내리는 것이다. (이런 이유로 타입 매개변수로 포인터를 취하는 unsafe 제네릭 타입은 만들 수 없다.)

System.Object가 제공하는 기능만 사용할 수 있다는 사실은 너무 제한적으로 보인다.

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

컴파일러에게 제약 조건을 알려준다는 것은 제네릭 타입에서 타입 매개변수로 주어진 타입을 System.Object에서 노출하는 수준 이상으로 사용할 수 있음을 알려주는 것이다.

제약 조건의 컴파일 입장

컴파일러 입장에선 두 가지 측면에서 도움이 된다.

첫째로 제네릭 타입을 작성할 때 도움이 된다.

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

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

제약 조건의 한 예로 타입 매개변수가 반드시 struct이어야 함을 지정할 수도 있고, 반드시 class이어야 함을 지정할 수도 있다.

이외에도 타입 매개변수로 주어진 타입이 반드시 구현해야 하는 인터페이스 목록을 제시할 수도 있다.

개인적으로 가장 많이 사용한 형태는 인터페이스의 구현을 강제하게 하는?

제약 조건을 설정하는 대신 형변환이나 런타임에 테스트를 수행하도록 코드를 작성할 수도 있다.

예를 들어 다음의 제네릭 메서든는 타입 매개변수 T에 대한 어떠한 제약 조건도 설정하지 않았다.

하지만 런타임에 IComparable 인터페이스로 형변환이 가능한지를 확인할 후 이 인터페이스가 정의하고 있는 메서드를 사용한다.

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

T가 반드시 IComparable를 구현해야 한다고 제약 조건을 설정하면 동일한 메서드를 더 간단하게 재작성할 수 있다.

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

두 번째 예와 같이 작성하면 런타임에 발생할 가능성이 있는 오류를 컴파일타임에 확인할 수 있다.

또한 코드도 매우 짧아지고 가독성도 높아진다.

이점 두가지.

첫 번째 예제에서는 코드를 통해 런타임 오류가 발생하지 않도록 했지만, 두 번째 예제에서는 컴파일러를 통해 런타임 오류가 발생하지 않도록 했다.

제약 조건이 없었다면 코딩상의 실수를 알려줄 수 있는 적절한 방법이 없었을 것이다.

제네릭 타입을 작성할 때 필요한 제약 조건이 있다면 반드시 이를 지정하자.

재약 조건이 없다면 타입의 오용 가능성이 높아지고, 사용자의 잘못된 예측으로 런타임 예외나 오류가 발생할 가능성이 높아지게 된다.

자신이 개발한 제네릭 타입을 직접 사용하는 경우라면 문제는 덜 하지만 다른 개발자가 같이 작업하는 경우라면 문서를 통해서 사용방법을 확인할 수밖에 없기에 (문서는 절대 안본다.)

이러한 제약 조건 자체가 코드에 녹아 있는 일종의 문서로 활용된다.

하지만 제약조건을 과도하게 설정하는 것 또한 좋지 않다.

타입 매개변수에 제약 조건을 많이 설정하면 제네릭 타입을 사용하는 것이 큰 부담이 된다.

필요한 제약 조건이라면 반드시 설정해야겠지만 이 경우에도 제약 조건의 수를 최소한으로 유지하도록 노력해야 한다.

최소화하기 위한 몇 가지 방법들이 있다.

가장 일반적인 방법으론 제네릭 타입 내에서 반드시 필요한 기능만을 제약 조건으로 설정하는 것이다.

IEquatable <T>를 예로 들어보면, 이 인터페이스는 흔히 사용되는 인터페이스 타입이기도 하거니와 새로운 타입을 작성할 때 자주 구현되기도 하는 인터페이스다.

앞의 예제를 Equals를 이용하여 재작성할 수 있다.

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

만약 이 코드가 IEquatable의 제약 조건을 가진 제네릭 클래스 안에 정의되었다고 가정해보자.

이 경우 앞의 메서드는 IEquatable.Equals 메서드를 호출하게 된다.

만약 적절한 제약 조건을 설정하지 않아서 IEquatable가 지원된다는 사실을 컴파일러에게 알려주지 않았다면 System.Object.Equals 메서드를 호출하게 된다.

이 예제는 C#의 제네릭과 C++의 템플릿 간의 차이를 극명하게 보여준다.

C# 컴파일러는 제약 조건에 설정된 정보만 이용하여 IL을 생성한다.

타입 매개변수로 지정된 타입이 설사 인스턴스화되었을 때 더 나은 메서드를 가졌다고 하더라도 제네릭 타입을 컴파일할 때 알려진 내용이 아니라면 사용하지 않는다.

특정 타입이 IEquatable를 구현하고 있다면 분명히 이 인터페이스를 활용하여 객체가 동일한지를 확인하는 것이 훨씬 더 효율적일 것이다.

또한 IEquatable를 제약 사항으로 설정하면 System.Object.Equals()를 정의한 메서드가 존재하는지 런타임에 확인할 필요가 없다.

게다가 타입 매개변수로 지정한 타입의 유형이 값 타입인 경우라면 박싱과 언방식도 피할 수 있다.

또한 이 인터페이스를 제약 조건으로 설정하면 가상 함수 호출 시 필요한 약간의 오버헤드도 피할 수 있다?!

이처럼 여러가지 이유로 개발자에게 IEquatable를 구현하도록 제약 조건을 설정하는 것은 괜찮아 보인다.

하지만 제약 조건을 설정할지의 여부를 어떤 기준으로 설정해야 할까?

System.Object.Equals메서드가 충분히 잘 동작하는 상황이라면? 혹은 성능이 저하되는 경우라면?

일반적인 권고 사항은 IEquatable와 같이 개선된 메서드를 사용하려 시도해보고, 만약 그것이 불가능한 경우라면 한 단계 낮은 수준의 메서드를 호출하도록 작성하는 것이다.

이를 위해 기본 기능을 제공하는 메서드 외에 자체적으로 구현한 메서드를 오버로드 형태로 제공하면 좋다.

즉, 이번 아이템의 시작 부분에서 보여준 AreEqual()을 오버로드 메서드 형태로 제공하는 것이다.

실제로 이 같은 접근 방식을 취하려면 더 많은 작업이 필요하다. 하지만 다른 개발자가 제약사항으로 인해 특별히 추가적인 작업을 하지 않더라도 타입 매개변수로 주어지는 타입의 기능을 확인한 후 가장 선호하는 인터페이스를 활용할 수 있다는 장점이 있다.

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

이 경우는 직접 주어진 타입을 런타임에 확인한 후 더 나은 메서드 가 더 나은 메서드가 있는지 살펴봐야 한다.

항상 원하는 메서드가 구현돼 있지는 않을 것이기 때문이다.

Equatable와 Comparable가 바로 이런 식으로 설계되고 구현되어 있다.

이러한 기법을 IEnumerable 혹은 IEnumerable 등의 인터페이스를 이미 제약 조건으로 설정한 타입에 대하여 추가적인 제약 조건을 설정하기 위한 용도로 확장해서 사용해볼 수도 있겠다.

기본 생성자 제약 조건을 설정할 때는 추가적으로 주의해야 할 부분이 있다.

때때로 new 대신 default()를 사용하면 new() 제약 사항이 필요 없을 수도 있다.

default() 연산자는 특정 타입에 따라 기본값을 가져오는데, 값 타입은 0이고 참조 타입은 null이다.

따라서 new()를 default()로 바꾸면 값 타입과 참조 타입에서 모두 사용할 수 있다.

하지만 참조 타입에 대해서는 default()와 new()가 매우 다른 의미를 가진다는 것에 주의해야 한다.

잘 작성된 제네릭 타입을 살펴보면 타입 매개변수로 지정한 타입의 기본값을 가져오기 위해서 default()를 사용하는 코드를 흔히 볼 수 있다.

다음 메서드는 주어진 조건을 만족하는 첫 번째 객체를 찾아내는 메서드인데, 조건을 만족하려면 해당 객체를 반환하고 그렇지 않으면 기본값을 반환한다.

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

이 메서드를 다음 메서드와 비교해보자. 다음 코드는 T타입의 객체를 생성하는 팩토리 메서드를 사용하는데, 이 팩토리 메서드가 null을 반환하면 기본 생성자를 호출한 후 그 값을 반환한다.

public delegate T FactoryFunc<T>();

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

default()를 사용하는 FirstOrDefault() 메서드는 특별한 제약 조건을 필요로 하지 않는다.

하지만 new T()를 사용하는 Factory() 메서드는 new() 제약 조건을 필요로 한다. (new() 제약 조건을 설정하지 않으면 컴파일 오류가 발생한다.)

참조 타입의 경우 null여부를 테스트해야 하기 때문에 값 타입과는 동작 방식이 사뭇 다르다.

Factory 메서드가 내부적으로 null 여부를 확인하는 코드를 가지고 있긴 하지만 값 타입에 대해서도 제한없이 이 메서드를 사용할 수 있다.

이것이 가능한 이유는 T가 값 타입일 경우 JIT컴파일러가 null 여부를 확인하는 코드를 제거하기 때문이다.

new(), struct, class를 제약 조건으로 설정하는 경우에는 항상 주의해야 한다.

앞의 예제를 통해서 이러한 제약 사항을 추가하면 객체를 생성하는 방식을 가정할 수 있음을 알아봤다.

또한 타입에 대한 기본값이 0이 될 수 있고, null이 될 수도 있다는 것도 알 수 있었다.

그리고 제네릭 타입 내에서 타입 매개변수로 주어진 타입을 이용하여 객체를 생성할 수도 있고 그것이 불가능할 수도 있다는 것도 알아봤다.

이상적으로는 이 3가지 제약 조건은 가능한 피하는 것이 좋다.

따라서 반드시 필요한 제약 조건인지 다시 한번 생각하는 과정이 필요하다.

정리

제네릭 타입을 사용할 사용자에게 개발자가 가정하고 있는 바를 알려주려면 제약 조건을 설정해야 한다.

하지만 제약 조건을 과도하게 설정하면 그 타입의 사용 빈도는 떨어질 수밖에 없다.

제네릭 타입을 만드는 이유가 다양한 시나리오에서 적용할 수 있는 범용 타입을 정의하기 위한 것임을 잊지 말자.

제약 조건을 만족시키기 위해 사용자가 추가로 해야하는 작업의 양과 제약 조건을 지정하여 얻을 수 있는 안정성 사이에서 적절히 균형을 유지하기 바란다.

필요한 최소한의 제약 조건만을 설정하라..!

하지만 타입 매개변수로 지정할 타입의 유형에 대하여 명확한 가정이 필요하다면 반드시 제약 조건으로 설정하라

댓글남기기