Devin.KR

제네릭 깊이 보기 - 제약 조건과 공변성

개발자KR 조회 0

이 장에서 배우는 것

기본서에서 List<T>와 Dictionary<TKey, TValue>를 쓰고, 간단한 제네릭 메서드를 직접 만들어 보았다. 이 장에서는 그 위에서 제네릭 설계가 어긋나는 지점을 다룬다. 타입 매개변수에 "이 타입은 무엇을 할 수 있어야 한다"는 조건을 거는 방법, 컴파일러가 타입 인자를 추론하는 규칙, 상속 관계가 제네릭 타입 사이로 이어지는 조건, 그리고 제네릭 클래스의 static 필드가 타입마다 따로 생긴다는 성질이다. 이 장의 예제는 택배 물류 센터의 소포 모델을 제네릭으로 다루는 작은 라이브러리다.

  • where 제약으로 타입 매개변수에 인터페이스, 생성자, 참조·값 형식 조건을 걸고, 제약이 어떤 멤버 호출을 가능하게 하는지 설명한다.
  • 제네릭 메서드에서 컴파일러가 타입 인자를 추론하는 경우와 추론에 실패하는 경우를 구분한다.
  • out(공변)과 in(반공변)의 방향을 판단하고, 인터페이스에 올바르게 선언한다.
  • 제네릭 클래스의 static 필드가 닫힌 타입마다 하나씩 생기는 성질을 캐시에 쓰고, 그 한계를 안다.

문제 상황

물류 센터 서비스에는 소포 종류가 여러 개다. 일반 소포(StandardParcel), 그 위에 허용 온도가 붙은 냉장 소포(ColdParcel), 그리고 여러 소포를 싣는 팔레트(Pallet)가 있다. 세 타입은 모두 IParcel 인터페이스로 식별자와 무게를 노출한다.

처음에는 "무게가 가장 큰 소포를 찾는" 메서드를 IParcel 목록을 받도록 만든다. 곧 불편이 드러난다. 냉장 소포 목록을 넘기면 결과가 IParcel이어서 MaxTempC를 읽으려면 형변환이 필요하다. 형변환은 컴파일러가 검사하지 못하므로 런타임 오류의 원인이 된다. 반대로 메서드를 소포 종류마다 복사하면 코드가 세 벌이 된다.

비슷한 문제가 계속 나온다. 냉장 소포 목록 List<ColdParcel>를 "소포 목록을 받는" 메서드에 넘기려 하면 컴파일러가 거부하는 경우가 있고, 통과하는 경우도 있다. 그 차이를 모르면 매번 시행착오로 형식을 맞추게 된다. 소포 종류별 일련번호를 발급하는 카운터를 만들 때는 종류마다 클래스를 새로 만들어야 하는지도 고민이 된다. 이 장은 이 세 가지를 한 흐름으로 해결한다.

where 제약과 타입 추론

제약은 "할 수 있는 일"을 선언한다

제네릭 메서드 안에서 T는 object가 가진 멤버만 쓸 수 있다. 컴파일러가 T에 대해 아는 것이 그것뿐이기 때문이다. where T : IParcel을 붙이면 "T는 반드시 IParcel을 구현한다"는 사실이 컴파일러에 전달되고, 메서드 안에서 item.WeightGrams를 형변환 없이 호출할 수 있다. 호출하는 쪽에서는 조건을 만족하지 않는 타입을 넘기면 컴파일 단계에서 오류가 난다. 검사가 런타임에서 컴파일 타임으로 옮겨오는 셈이다.

자주 쓰는 제약은 다음과 같다. 제약은 쉼표로 여러 개를 나열하며, class·struct·notnull은 맨 앞에, new()는 맨 뒤에 둔다.

자주 쓰는 where 제약과 그것이 허용하는 것
제약의미메서드 안에서 가능해지는 것이 장의 예
where T : IParcel인터페이스 구현인터페이스 멤버 호출Heaviest
where T : new()public 매개변수 없는 생성자new T()NewBlank
where T : class참조 형식null 비교, 참조 동일성 비교표에서만 소개
where T : structNullable이 아닌 값 형식T?를 Nullable<T>로 사용표에서만 소개
where T : notnullnull 이 될 수 없는 타입nullable 경고 없이 사용표에서만 소개

제약은 넓게 걸수록 재사용성이 높고 좁게 걸수록 안에서 할 수 있는 일이 많다. where T : StandardParcel처럼 구체 클래스에 묶으면 Pallet은 쓸 수 없다. 메서드 본문이 실제로 필요로 하는 능력만큼만 제약을 건다. 각 제약의 전체 목록은 타입 매개 변수에 대한 제약 조건 문서에서 확인할 수 있다.

타입 인자 추론

제네릭 메서드를 호출할 때 Heaviest<ColdParcel>(colds)처럼 타입 인자를 적지 않아도 되는 것은 컴파일러가 인자의 타입에서 T를 추론하기 때문이다. 규칙은 다음과 같이 정리할 수 있다.

  • 추론의 재료는 인자의 타입뿐이다. 반환 타입과 where 제약은 재료가 아니다. T가 인자에 나타나지 않는 NewBlank<T>()는 항상 NewBlank<Pallet>()처럼 명시해야 한다.
  • 같은 T가 인자 두 곳에 나오면 후보가 여러 개가 된다. 후보 중 나머지 후보가 모두 암시적으로 변환될 수 있는 타입이 하나 있으면 그 타입이 선택된다. Lighter(일반 소포, 냉장 소포)는 ColdParcel이 StandardParcel로 변환되므로 T가 StandardParcel이 된다. 서로 변환되지 않는 두 타입, 예컨대 소포와 int를 넘기면 추론이 실패한다.
  • 추론된 T는 인자의 정적 타입이다. 런타임 타입이 아니다. var로 받은 변수의 타입 역시 이 T다.

첫 번째 규칙 때문에 "메서드가 T를 돌려주게 하는 것"이 제네릭의 핵심 이점이 된다. Heaviest에 List<ColdParcel>를 넘기면 ColdParcel이 그대로 돌아오고, 형변환이 필요 없다.

공변성과 반공변성

왜 List<ColdParcel>는 List<IParcel>가 아닌가

ColdParcel은 IParcel이다. 그러나 List<ColdParcel>를 List<IParcel> 변수에 대입하는 것은 막혀 있다. 대입이 허용된다고 가정하면 그 변수로 StandardParcel을 Add할 수 있고, 그러면 냉장 소포만 담겨야 할 목록에 일반 소포가 들어간다. 타입 안전성이 깨지므로 제네릭 클래스는 기본적으로 불변(invariant)이다.

그런데 위험은 "넣는" 방향에서만 생긴다. 목록에서 꺼내기만 하는 쪽에서는 ColdParcel을 IParcel로 읽어도 문제가 없다. 이 관찰이 in/out 변성 선언의 근거다.

out 은 공변, in 은 반공변

제네릭 인터페이스와 델리게이트의 타입 매개변수에는 변성 키워드를 붙일 수 있다(클래스·구조체에는 붙일 수 없다). 선언하면 컴파일러가 인터페이스 안의 모든 멤버를 검사해서, 선언과 어긋나는 위치에 T가 쓰이면 오류를 낸다.

  • out T는 T를 반환값 위치에만 쓴다. 그러면 IParcelSource<ColdParcel>를 IParcelSource<IParcel>로 대입할 수 있다. 상속의 방향과 같아서 공변(covariant)이라 한다.
  • in T는 T를 매개변수 위치에만 쓴다. 그러면 IParcelSink<IParcel>를 IParcelSink<ColdParcel>로 대입할 수 있다. 상속의 반대 방향이라 반공변(contravariant)이라 한다. 모든 소포를 받는 라벨 프린터는 냉장 소포만 받는 자리에도 쓸 수 있다는 뜻이다.
out 은 자식에서 부모 방향으로, in 은 부모에서 자식 방향으로 대입이 허용된다.
변성 선언별 T 의 위치와 대입 방향
선언T 가 나올 수 있는 위치대입 방향BCL 예
없음(불변)어디든같은 타입만List<T>
out T반환값자식 타입 → 부모 타입IEnumerable<out T>
in T매개변수부모 타입 → 자식 타입IComparer<in T>

변성에는 두 가지 제한이 있다. 첫째, 변환은 참조 변환이 성립하는 경우에만 동작한다. 그래서 T가 값 형식이면 공변·반공변이 적용되지 않는다. 값 형식을 IParcel로 바꾸려면 박싱이 필요한데, 변성은 참조 그대로 재해석하는 기능이기 때문이다. 둘째, 배열은 예전부터 공변이지만 안전하지 않다. IParcel[] slots = new ColdParcel[2];는 컴파일되고, 여기에 StandardParcel을 저장하면 런타임에 ArrayTypeMismatchException이 발생한다. 아래 완성 코드에서 이 동작을 직접 확인한다. 변성의 공식 설명은 공변성과 반공변성 문서에 있다.

제네릭 캐시: static 필드는 닫힌 타입마다 하나

IdSequence<T>처럼 타입 매개변수가 채워지지 않은 형태를 열린 제네릭 타입, IdSequence<ColdParcel>처럼 채워진 형태를 닫힌 제네릭 타입(constructed type)이라 한다. 런타임은 닫힌 타입마다 별개의 타입으로 취급하고, 그 결과 제네릭 클래스에 선언한 static 필드와 static 생성자도 닫힌 타입마다 따로 존재하고 따로 실행된다. 상속 관계는 영향을 주지 않는다. IdSequence<StandardParcel>와 IdSequence<ColdParcel>는 서로 무관한 타입이다.

열린 제네릭 타입 하나에서 닫힌 타입 세 개가 생기고 각각 자기만의 static 필드를 가진다.

이 성질을 이용하면 "타입별로 한 번만 계산해 두고 재사용하는" 캐시를 딕셔너리 없이 만들 수 있다. 종류별 일련번호나 접두어를 한 번만 계산해 두는 것이 그 예다. 딕셔너리 조회 없이 필드를 바로 읽으므로 코드도 짧다.

주의할 점이 있다. 첫째, static 생성자가 실행되는 시점은 "닫힌 타입을 처음 사용할 때"다. 처음 사용하는 시점은 프로그램 흐름에 달려 있으므로, 초기화에 부수 효과를 넣을 때는 순서를 의식해야 한다. 둘째, 이렇게 만든 캐시는 프로세스가 끝날 때까지 남는다. 셋째, 가변 상태(아래 예의 _last)를 여러 스레드가 동시에 바꾸면 값이 어긋난다. 이 장의 예제는 스레드 하나로만 실행한다. 동시 접근을 안전하게 다루는 방법은 동시성을 다루는 장에서 따로 배운다.

완성 코드

dotnet new console -n ParcelGenerics -f net10.0으로 만든 프로젝트의 Program.cs를 아래 내용으로 바꾼다. 기본 템플릿의 암시적 using 과 nullable 설정을 그대로 사용하므로 using 문은 적지 않았다.

var standards = new List<StandardParcel>
{
    new("S-1", 1200),
    new("S-2", 800),
    new("S-3", 2300),
};
var colds = new List<ColdParcel>
{
    new("C-1", 500, 4),
    new("C-2", 900, -18),
};

Console.WriteLine("[1] where 제약과 타입 추론");
var heavyStandard = ParcelTools.Heaviest(standards);
var heavyCold = ParcelTools.Heaviest(colds);
Console.WriteLine($"일반 최중량: {heavyStandard.Id} {heavyStandard.WeightGrams}g, T={ParcelTools.TypeName(heavyStandard)}");
Console.WriteLine($"냉장 최중량: {heavyCold.Id} {heavyCold.WeightGrams}g, 허용 {heavyCold.MaxTempC}도, T={ParcelTools.TypeName(heavyCold)}");
var blank = ParcelTools.NewBlank<Pallet>();
Console.WriteLine($"빈 팔레트: {blank.Id} {blank.WeightGrams}g");
var lighter = ParcelTools.Lighter(standards[0], colds[0]);
Console.WriteLine($"더 가벼운 소포: {lighter.Id}, T={ParcelTools.TypeName(lighter)}");

Console.WriteLine("[2] 공변과 반공변");
Console.WriteLine($"일반 합계: {ParcelTools.TotalWeight(standards)}g");
Console.WriteLine($"냉장 합계: {ParcelTools.TotalWeight(colds)}g");

IParcelSource<IParcel> source = new QueueSource<ColdParcel>(colds);
while (source.HasNext)
{
    Console.WriteLine($"  꺼냄: {source.Next().Id}");
}

IParcelSink<IParcel> anyPrinter = new LabelPrinter();
IParcelSink<ColdParcel> coldSink = anyPrinter;
foreach (var cold in colds)
{
    coldSink.Accept(cold);
}

IParcel[] slots = new ColdParcel[2];
slots[0] = colds[0];
try
{
    slots[1] = standards[0];
    Console.WriteLine("배열: 저장됨");
}
catch (ArrayTypeMismatchException)
{
    Console.WriteLine("배열: ArrayTypeMismatchException");
}

Console.WriteLine("[3] 제네릭 캐시");
Console.WriteLine($"호출 전 초기화 수: {CacheStats.Initialized}");
Console.WriteLine(IdSequence<StandardParcel>.Next());
Console.WriteLine(IdSequence<StandardParcel>.Next());
Console.WriteLine(IdSequence<ColdParcel>.Next());
Console.WriteLine(IdSequence<Pallet>.Next());
Console.WriteLine($"초기화된 닫힌 타입 수: {CacheStats.Initialized}");
Console.WriteLine(IdSequence<StandardParcel>.Next());
Console.WriteLine($"다시 호출 후 초기화 수: {CacheStats.Initialized}");

public interface IParcel
{
    string Id { get; }
    int WeightGrams { get; }
}

public record StandardParcel(string Id, int WeightGrams) : IParcel;

public record ColdParcel(string Id, int WeightGrams, int MaxTempC) : StandardParcel(Id, WeightGrams);

public class Pallet : IParcel
{
    public string Id { get; set; } = "PALLET-0";
    public int WeightGrams { get; set; }
}

public interface IParcelSource<out T> where T : IParcel
{
    bool HasNext { get; }
    T Next();
}

public interface IParcelSink<in T> where T : IParcel
{
    void Accept(T parcel);
}

public class QueueSource<T> : IParcelSource<T> where T : IParcel
{
    private readonly Queue<T> _queue;

    public QueueSource(IEnumerable<T> items)
    {
        _queue = new Queue<T>(items);
    }

    public bool HasNext => _queue.Count > 0;

    public T Next() => _queue.Dequeue();
}

public class LabelPrinter : IParcelSink<IParcel>
{
    public void Accept(IParcel parcel)
    {
        Console.WriteLine($"  라벨: {parcel.Id} {parcel.WeightGrams}g");
    }
}

public static class ParcelTools
{
    public static T Heaviest<T>(IEnumerable<T> items) where T : IParcel
    {
        using var e = items.GetEnumerator();
        if (!e.MoveNext())
        {
            throw new InvalidOperationException("빈 목록이다.");
        }
        T best = e.Current;
        while (e.MoveNext())
        {
            if (e.Current.WeightGrams > best.WeightGrams)
            {
                best = e.Current;
            }
        }
        return best;
    }

    public static T Lighter<T>(T a, T b) where T : IParcel
    {
        return a.WeightGrams <= b.WeightGrams ? a : b;
    }

    public static T NewBlank<T>() where T : IParcel, new()
    {
        return new T();
    }

    public static int TotalWeight(IEnumerable<IParcel> parcels)
    {
        int total = 0;
        foreach (var p in parcels)
        {
            total += p.WeightGrams;
        }
        return total;
    }

    public static string TypeName<T>(T value) => typeof(T).Name;
}

public static class CacheStats
{
    public static int Initialized;
}

public static class IdSequence<T> where T : IParcel
{
    private static readonly string Prefix;
    private static int _last;

    static IdSequence()
    {
        Prefix = typeof(T).Name[..3].ToUpperInvariant();
        CacheStats.Initialized++;
    }

    public static string Next()
    {
        _last++;
        return $"{Prefix}-{_last:000}";
    }
}

줄별 해설

소포 모델. IParcel은 Id와 WeightGrams만 읽는 인터페이스다. StandardParcel과 ColdParcel은 record 이고 후자가 전자를 상속한다. 위치 매개변수 Id, WeightGrams는 기반 record 로 그대로 전달되며, 속성은 기반 record 가 이미 갖고 있다. Pallet은 값을 바꿀 수 있는 클래스이고 기본 생성자를 갖는다. 뒤의 new() 제약을 시연하기 위한 것이다.

Heaviest. where T : IParcel 덕분에 e.Current.WeightGrams를 형변환 없이 읽는다. 빈 시퀀스에서는 default를 돌려주면 T가 null이 될 수 있으므로, 예외를 던지는 쪽으로 처리했다. 반환 타입이 T이므로 colds를 넘기면 ColdParcel이 돌아와서 heavyCold.MaxTempC를 바로 읽는다.

Lighter와 TypeName. Lighter(standards[0], colds[0])에서 후보는 StandardParcel과 ColdParcel이다. ColdParcel은 StandardParcel로 변환되지만 반대는 안 되므로 T는 StandardParcel로 정해진다. 실제 반환된 객체는 C-1(냉장 소포)이지만 정적 타입이 StandardParcel이므로 TypeName은 StandardParcel을 출력한다. TypeName은 typeof(T)를 출력하는 메서드여서, 컴파일러가 추론한 T를 눈으로 확인하는 용도다.

NewBlank. 인자가 없으므로 ParcelTools.NewBlank<Pallet>()처럼 타입 인자를 직접 적어야 한다. new() 제약이 없으면 new T()는 컴파일되지 않는다.

TotalWeight. 매개변수가 IEnumerable<IParcel>이다. IEnumerable<out T>가 공변이므로 List<StandardParcel>와 List<ColdParcel>를 그대로 넘길 수 있다. 제네릭 메서드로 만들지 않아도 된다.

IParcelSource / QueueSource. IParcelSource<out T>에서 T는 Next()의 반환 위치에만 있다. 그래서 QueueSource<ColdParcel> 객체를 IParcelSource<IParcel> 변수에 담을 수 있다. 큐 내부는 Queue<T>이며 이 클래스 자체는 변성이 없다. 변성은 인터페이스를 통해 노출되는 창구에만 적용된다.

IParcelSink / LabelPrinter. IParcelSink<in T>에서 T는 Accept의 매개변수 위치에만 있다. LabelPrinter는 IParcel을 받는 프린터다. 이것을 IParcelSink<IParcel>로 잡은 뒤 IParcelSink<ColdParcel> 변수에 대입하는 것이 반공변이다. 냉장 소포는 IParcel이므로 프린터가 처리하지 못하는 경우는 없다.

배열 실험. new ColdParcel[2]를 IParcel[]로 담는 것은 컴파일된다. slots[0] = colds[0]은 실제 요소 타입과 맞으므로 성공한다. slots[1] = standards[0]은 컴파일러가 막지 못하고 런타임에 검사되어 ArrayTypeMismatchException이 발생한다. "배열: 저장됨"은 출력되지 않는다.

CacheStats / IdSequence. static 생성자는 닫힌 타입마다 처음 접근할 때 한 번 실행된다. 여기서 접두어를 계산해 Prefix에 저장하고, 비제네릭 클래스 CacheStats의 카운터를 올린다. 이 카운터는 열린 타입 바깥에 있으므로 닫힌 타입들이 공유하고, 몇 개의 닫힌 타입이 초기화됐는지 세는 데 쓸 수 있다. typeof(T).Name[..3]은 이름의 앞 세 글자이고, {_last:000}은 세 자리로 0을 채우는 서식이다. 마지막의 IdSequence<StandardParcel>.Next() 호출은 이미 초기화된 타입이므로 카운터가 3에서 변하지 않는다.

실행 결과

$ dotnet run
[1] where 제약과 타입 추론
일반 최중량: S-3 2300g, T=StandardParcel
냉장 최중량: C-2 900g, 허용 -18도, T=ColdParcel
빈 팔레트: PALLET-0 0g
더 가벼운 소포: C-1, T=StandardParcel
[2] 공변과 반공변
일반 합계: 4300g
냉장 합계: 1400g
  꺼냄: C-1
  꺼냄: C-2
  라벨: C-1 500g
  라벨: C-2 900g
배열: ArrayTypeMismatchException
[3] 제네릭 캐시
호출 전 초기화 수: 0
STA-001
STA-002
COL-001
PAL-001
초기화된 닫힌 타입 수: 3
STA-003
다시 호출 후 초기화 수: 3

실무에서 자주 틀리는 것

List<ColdParcel>를 List<IParcel>에 대입하려 한다

메서드의 매개변수를 List<IParcel>로 선언하면 냉장 소포 목록을 넘길 수 없다.

// 틀린 코드: 컴파일 오류
static int Count(List<IParcel> parcels) => parcels.Count;
Count(colds);   // List<ColdParcel> 를 List<IParcel> 로 변환할 수 없다

// 고친 코드: 읽기만 하는 매개변수는 공변 인터페이스로 받는다
static int Count(IReadOnlyList<IParcel> parcels) => parcels.Count;
Count(colds);

메서드가 목록에 요소를 추가하지 않는다면 IEnumerable<T>나 IReadOnlyList<T>로 받는 것이 기본이다. 추가·삭제가 필요하다면 그때는 불변인 List<T>가 맞고, 호출하는 쪽에서 타입을 맞춰야 한다.

값 형식에는 공변이 적용되지 않는다

소포를 구조체로 바꾸면 방금까지 되던 대입이 막힌다.

public readonly record struct BoxParcel(string Id, int WeightGrams) : IParcel;

var boxes = new List<BoxParcel> { new("B-1", 300) };

// 틀린 코드: 컴파일 오류. 값 형식은 변성 변환 대상이 아니다
int total = ParcelTools.TotalWeight(boxes);

// 고친 코드 1: 명시적으로 변환한다. 요소마다 박싱이 생긴다
int total1 = ParcelTools.TotalWeight(boxes.Cast<IParcel>());

// 고친 코드 2: 제네릭 메서드로 만들어 박싱 없이 처리한다
static int TotalWeight<T>(IEnumerable<T> parcels) where T : IParcel
{
    int total = 0;
    foreach (var p in parcels) total += p.WeightGrams;
    return total;
}

구조체를 쓰는 이유가 할당 절감이라면 Cast는 그 이점을 없앤다. 제약이 걸린 제네릭 메서드는 값 형식에서도 박싱 없이 인터페이스 멤버를 호출할 수 있다. 이 부분은 메모리를 다루는 장에서 다시 다룬다.

제약을 걸지 않고 형변환으로 때운다

// 틀린 코드: 제약이 없어서 형변환에 의존한다
static int WeightOf<T>(T item) => ((IParcel)(object)item!).WeightGrams;
WeightOf("문자열");   // 컴파일은 되지만 실행 중 InvalidCastException

// 고친 코드: 제약으로 컴파일 타임에 막는다
static int WeightOf<T>(T item) where T : IParcel => item.WeightGrams;
// WeightOf("문자열");   // 컴파일 오류로 바뀐다

제네릭을 쓰는 이유가 컴파일러 검사를 얻기 위해서인데, 안에서 (object)로 되돌리면 그 이점이 사라진다. 메서드 안에서 형변환이 필요해 보이면 제약을 먼저 의심한다.

제네릭 static 필드를 무한히 자라는 전역 저장소로 쓴다

// 틀린 코드: 타입마다 목록이 하나씩 생기지만 비워지지 않고, 동시 접근에도 안전하지 않다
public static class History<T> where T : IParcel
{
    public static readonly List<T> Items = new();
}
History<ColdParcel>.Items.Add(cold);   // 처리한 소포가 프로세스 종료까지 쌓인다

// 고친 코드: 정적 캐시에는 타입당 한 번 정해지는 읽기 전용 값만 둔다
public static class Labels<T> where T : IParcel
{
    public static readonly string Prefix = typeof(T).Name[..3].ToUpperInvariant();
}
// 늘어나는 상태는 수명을 관리할 수 있는 인스턴스가 들고 있게 한다
public sealed class ParcelHistory<T> where T : IParcel
{
    private readonly List<T> _items = new();
    public void Add(T item) => _items.Add(item);
}

정적 필드는 가비지 수집 대상이 되지 않는 뿌리(root)이므로 여기에 참조를 쌓으면 해제되지 않는다. 테스트에서도 정적 상태는 테스트끼리 서로 영향을 준다. 이 문제는 테스트를 다루는 장에서 다시 만난다.

한눈에 보기

이 장의 개념 요약
주제핵심 규칙실수하기 쉬운 점
where 제약필요한 능력만큼 건다. 멤버 호출 가능 여부는 제약이 결정한다.구체 클래스에 묶어 재사용성을 잃는다.
타입 추론인자의 정적 타입만 재료다. 반환 타입과 제약은 쓰지 않는다.인자 없는 메서드는 타입 인자를 직접 적는다.
out T반환 위치만, 자식에서 부모로 대입한다.값 형식에는 적용되지 않는다.
in T매개변수 위치만, 부모에서 자식으로 대입한다.인터페이스·델리게이트에만 선언할 수 있다.
제네릭 캐시static 필드는 닫힌 타입마다 하나다.가변 상태를 쌓으면 해제되지 않고 동시성에 취약하다.

다음 장에서는 동작 자체를 값으로 넘기는 델리게이트와 람다를 다룬다. 이 장에서 본 변성 규칙은 Func, Action 같은 델리게이트 타입에도 똑같이 적용된다.

연습 문제

  1. 다음 인터페이스는 컴파일되지 않는다. 이유를 설명하고, 두 가지 방식으로 고쳐라.
    public interface IShelf<out T> where T : IParcel
    {
        T Take();
        void Put(T item);
    }
    
  2. 다음 코드의 출력을 예상하라.
    public static class Counter<T> { public static int Hits; }
    
    Counter<int>.Hits++;
    Counter<int>.Hits++;
    Counter<string>.Hits++;
    Counter<object>.Hits += 5;
    Console.WriteLine($"{Counter<int>.Hits} {Counter<string>.Hits} {Counter<object>.Hits}");
    
  3. static T Larger<T>(T a, T b) where T : IParcel이 있다. 아래 호출 중 컴파일되는 것을 골라라. standards는 List<StandardParcel>, colds는 List<ColdParcel>이다.
    (가) Larger(standards[0], standards[1])
    (나) Larger(standards[0], colds[0])
    (다) Larger(standards[0], 5)
    (라) Larger<IParcel>(standards[0], colds[0])
    
  4. 본문의 TotalWeight(IEnumerable<IParcel>)와 제네릭 버전 TotalWeight<T>(IEnumerable<T>) where T : IParcel이 결과에서 달라지는 경우를 두 가지 들어라.

정답과 해설

  1. out T는 T를 반환 위치에만 허용한다. Put(T item)은 T가 매개변수 위치에 있어서 오류가 난다. 이 선언이 허용된다면 IShelf<IParcel> 변수로 StandardParcel을 넣을 수 있게 되어, 실제로는 냉장 소포 선반인 객체에 일반 소포가 들어가기 때문이다. 고치는 방법은 두 가지다. 첫째, out을 지워 불변 인터페이스로 만든다(IShelf<T>). 둘째, 읽기와 쓰기를 나눠 IShelfReader<out T>(Take만)와 IShelfWriter<in T>(Put만)를 만들고 클래스가 둘 다 구현하게 한다. 두 번째 방법은 읽는 쪽에는 공변을, 쓰는 쪽에는 반공변을 각각 적용할 수 있다.
  2. 2 1 5가 출력된다. Counter<int>, Counter<string>, Counter<object>는 서로 다른 닫힌 타입이므로 Hits 필드가 각각 따로 있다. string이 object를 상속하더라도 카운터가 공유되지는 않는다.
  3. (가), (나), (라)가 컴파일된다. (가)는 두 인자가 같은 타입이라 T가 StandardParcel이다. (나)는 후보 StandardParcel과 ColdParcel 중 ColdParcel이 StandardParcel로 변환되므로 T가 StandardParcel로 추론된다. (다)는 int와 StandardParcel이 서로 변환되지 않아 추론이 실패하고, 설령 int가 후보가 되더라도 IParcel 제약을 만족하지 못한다. (라)는 타입 인자를 직접 적은 것이고, 두 인자가 모두 IParcel로 변환되므로 통과한다.
  4. 첫째, 반환 타입이 필요한 경우다. 무게 합계처럼 int만 돌려주는 메서드에는 차이가 없지만, Heaviest처럼 요소 자체를 돌려주는 메서드는 제네릭이어야 호출자가 구체 타입을 그대로 받는다. 둘째, 요소가 값 형식인 경우다. IEnumerable<IParcel> 버전은 값 형식 목록을 받지 못하고, Cast로 억지로 넘기면 요소마다 박싱이 일어난다. 제네릭 버전은 값 형식 그대로 처리한다. 반대로 참조 형식만 다루고 결과 타입이 구체 타입일 필요가 없다면 공변 인터페이스를 받는 비제네릭 메서드로 충분하고, 호출부와 시그니처가 더 단순하다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.