타입 매개변수에 대한 제약 조건(Constraint)은 클래스가 작업을 올바르게 수행학 위해서 타입 매개변수로 전달할 수 있는 타입의 유형을 제한하는 방법이다. 개발자는 올바르게 작업을 수행하기 위한 최소한의 제약 조건만을 설정해야 한다.
Where키워드
C#에서 제네릭 타입의 제약 조건은 Where키워드를 사용하여 설정된다. 아래 코드는 T가 IComparable<T>인터페이스를 구현하도록 제약조건을 사용하는 예제이다.
public class Example<T> where T : IComparable<T>
{
public int CompareValues(T value1, T value2)
{
return value1.CompareTo(value2);
}
}
Example<int> intExample = new Example<int>();
int result = intExample.CompareValues(5, 10);
01 제약 조건의 목적
제약 조건은 타입 매개변수에 대한 제한을 설정하는데 이는 클래스가 올바르게 작동하려면 특정 타입을 사용해야 한다는 것을 나타낸다. 이는 개발자가 최소한의 필요한 제약을 설정하여 코드의 안전성을 높이고 오류를 미리 방지할 수 있다.
02 미설정시의 문제
만약 제약 조건을 설정하지 않으면 런타임에 더 많은 검사를 수행해야 한다. 형변환이 더 자주 발생하고 리플렉션을 사용해야할 가능성이 높아지며 잘못된 타입으로 인한 런타임 오류의 발생 가능성이 높아진다.
03 과도한 제약 조건의 문제
그러나 반대로 불필요한 제약 조건을 설정하면 클래스를 사용하기 위해 과도한 추가 작업이 필요할 수 있다. 이는 개발자가 불필요한 제약을 설정함으로써 코드 작성의 번거러움을 가져올 수 있다.
04 컴파일러의 역할
제약 조건을 설정하면 컴파일러가 해당 타입에 대해 올바른 IL(Intermediate Language)을 생성할 수 있게 된다. 컴파일러는 제네릭 타입에 대한 IL코드를 올바르게 생성하는 책임이 있으며 타임 매개변수에 충분한 정보가 없더라도 올바른 IL코드를 생성할 책임이 있다.
제약 조건은 제네릭 타입에 대해 우리가 가정하고 있는 사실을 컴파일러와 다른 개발자에게 알려주는 용도로 사용된다. 컴파일러에게 제약 조건을 알려준다는 것은 제네릭 타입에서 타입 매개변수로 주어진 타입을 System.Object에서 노출하는 수준 이상으로 사용할 수 있음을 알려주는 것이다.
1. 제약 조건의 활용
01 컴파일러 지원
제약 조건은 컴파일러에게 특정한 조건을 만족하는 타입 매개변수를 사용하는 것을 보장한다. 이를 통해 컴파일러는 제네릭 타입이나 메서드의 사용자가 정상적인 제약으 지키고 있는지 확인할 수 있다.
02 코드의 안정성
제약 조건을 사용하면 코드 안정성이 향상된다. 제네릭 타입이나 메서드가 기대하는 기능을 타입이 구현하고 있는지 확인할 수 있기 때문이다.
2. 제약 조건의 예시
01인터페이스 구현
특정 인터페이스를 구현하도록 강제한다.
public interface IExampleInterface
{
void ExampleMethod();
}
public class ExampleClass<T> where T : IExampleInterface
{
// T는 IExampleInterface를 반드시 구현해야 함
}
02 클래스 또는 값 타입 제약
클래스 또는 값 타입으로 제한할 수 있다.
public class ExampleClass<T> where T : class
{
// T는 클래스여야 함
}
public class ExampleStruct<T> where T : struct
{
// T는 값 타입(구조체)이어야 함
}
03 기타 제약 조건
new()제약을 사용하여 매개변수 없는 생성자가 있는 타입을 강제할 수 있다.
public class ExampleClass<T> where T : new()
{
// T는 매개변수 없는 생성자를 가져야 함
}
3. 제약 조건의 장점
01 안전성 향상
제약 조건을 컴파일 타임에 코드의 안전성이 향상되며 런타임 에러를 방지할 수 있다.
02 명시적 규칙 설정
제네릭을 사용하는 개발자에게 명시적인 규칙을 제공하여 코드의 의도를 명확하게 전달할 수 있다.
03 컴파일러 지원 활용
제약을 설정하면 컴파일러가 더 잘 코드를 최적화하고 검증할 수 있다.
제약 조건을 최소화하는 방법
제네릭 타입 내에서 반드시 필요한 기능만 제약 조건으로 설정
IEquatable<T>를 예로 들어보자
이 인터페이스는 매우 흔히 사용되는 인터페이스 타입이기도 하거니와 새로운 타입을 작성할 때 자주 구현하곤 하는 인터페이스다.
public static bool AreEqual<T>(T left, T right) where T : IEquatable<T>
{
return left.Equals(right);
}
where T : IEquatable<T> 부분이 제약 조건을 나타낸다. 이제 이 메서드를 사용할 때 컴파일러는 T가 IEquatable<T>를 구현하는 타입으로 제한된다. 따라서 IEquatable<T>.Equals를 호출할 수 있게 된다.
만약 제약 조건이 없었다면 System.Object.Equals()를 호출했을 것이다.
01 런타임 성능 최적화
IEquatable<T>를 사용하면 런타임에서 System.Object.Equals 호출하는 대신 해당 타입에 구현된 IEquatable<T>.Equals()를 호출할 수 있다. 이는 가상 메서드 호출을 피하고,구체적으로 해당 타입의 메서드로 직접 분기하여 호출할 수 있기 때문에 성능상 이점이.
02 박싱과 언박싱 회피
값 타입에 대한 IEquatable<T>를 제약 조건으로 설정하면 해당 값 타입이 구체적인 메서드를 구현하게 되어 박싱과 언박싱을 피할 수 있다. 값 타입은 일반적으로 System.Object로 박싱되고 다시 언박싱되는 과정에서 성능 손실이 발생할 수 있는데 이를 피할 수 있다.
03 컴파일 타임 안전성
제네릭을 사용할 때 컴파일 타임에 타입 안전성을 보장할 수 있다. 제약 조건을 통해 컴파일러에게 특정 기능이 보장되는 타입만을 허용하도록 하면서 코드의 안전성을 높일 수 있다.
제약 조건을 설정할지의 여부를 어떤 기준으로 결정해야 할까?
01 오버로드 메서드를 제공
개선된 메서드(IEquatable<T>와 같은)를 사용하려 노력하고 불가능한 경우에는 한 단계 낮은 수준의 메서드를 호출하는 것이 권장된다. 기본 기능을 제공하는 메서드 외에도 타입에 맞춰 자체적으로 구현한 메서드를 오버로드 형태로 제공하면 좋다. 다른 개발자가 추가 작업을 하지 않아도 타입의 기능을 확인한 후 인터페이스를 활용하는 장점이 있다.
02 메서드 있는지 확인
런타임에 타입을 확인하고 더 나은 메서드가 있는지 살펴봐야 한다. 모든 타입이 원하는 메서드를 구현하지 않을 수 있기 때문이다. Equatable<T>와 Comparable<T>와 같은 인터페이스는 이런 상황에서 활용될 수 다.
03 default() 활용
new() 제약 사항을 피하려면 default(T)를 사용할 수 있다. default() 연산자는 특정 타입의 기본 값을 가져오며, 값 타입에 대해서는 0을, 참조 타입에 대해서는 null을 가져온다.
new T()를 호출할 것으로 예상되더라도 new T() 대신 default(T)를 사용하는 것이 더 일반적이고 유연한 방법일 수 있다. 참조 타입에 대해서는 default()와 new()가 다른 의미를 가지므로 주의가 필요다.
new() 제약 조건
참조 타입에만 적용할 수 있다. 반드시 기본 생성자 (파라미터가 없는 생성자)를 가져야 한다. 이는 new T()와 같은 코드를 제네릭 함수 내에서 사용할 수 있게 한다. 구체적으로, struct나 enum은 항상 기본 생성자를 가지고 있으므로 이 제약 조건을 설정하지 않아도 사용할 수 있다. class의 경우, 구현에 따라 기본 생성자를 가지고 있거나 없을 수 있다.
public class Example<T> where T : new()
{
public T CreateInstance()
{
return new T();
}
}
다른 제약 조건과 함께 new()제약 조건을 사용하는 경우 마지막에 지정해야 한다.
public class ItemFactory2<T> where T : IComparable, new()
{ }
default() 제약 조건
default 키워드는 C#에서 변수나 제네릭 타입 매개변수에 대한 기본값을 반환한다. 제약 조건이 필요하지 않으며, 어떠한 타입에도 사용 가능하다. class의 경우 default는 null을 반환하며, struct의 경우 해당 값 타입의 기본값을 반환한다.
public class Example<T>
{
public T CreateInstance()
{
return default(T);
}
}
default 기본값
int intDefault = default(int); // 0
float floatDefault = default(float); // 0.0f
bool boolDefault = default(bool); // false
char charDefault = default(char); // '\0'
string stringDefault = default(string); // null
// 사용자 정의 클래스
public class MyClass
{
public int MyInt { get; set; }
public string MyString { get; set; }
}
MyClass classDefault = default(MyClass); // 각 멤버에 대한 기본값으로 초기화된 객체 (MyInt: 0, MyString: null)
결론
제네릭 타입을 사용할 사용자에게 개발자가 가정하고 있는 바를 알려주려면 제약 조건을 설정해야 한다. 하지만 제약 조건을 과도하게 설정하면 그 타입의 사용 빈도는 떨어질 수 밖에 없었다.
제네릭 타입을 만드는 이유가 다양한 시나리오에서 적용할 수 있는 범용 타입을 정의하기 위함을 잊지말자
본 게시글은 Effective C#을 읽고 정리하였습니다.
'책 > Effective C#' 카테고리의 다른 글
Effective C# - Item20 IComparable<T>와 IComparer<T>를 이용하여 객체의 선후 관계를 정의하라 (0) | 2023.12.10 |
---|---|
Effective C# - Item19 런타임에 타입을 확인하여 최적의 알고리즘을 사용하라 (0) | 2023.12.10 |
Effective C# - 제네릭타입, 닫힌 제네릭 타입, 열린 제네릭 타입, JLT Compiler, IL (1) | 2023.12.05 |
Effective C# - Item 17 표준 Dispose패턴을 구현하라 (0) | 2023.12.03 |
Effective C# - Item 16 생성자 내에서는 절대로 가상 함수를 호출하지 말라 (0) | 2023.12.03 |
댓글