Devin.KR

Visual Basic · 심화

LINQ·비동기·설계로 깊어지는 Visual Basic

제네릭 심화 - 제약 조건과 변성

Of T 제약(Class·New·인터페이스), 제네릭 메서드, In·Out 변성, 제네릭 저장소 만들기

개발자KR · 원고 갱신

이 장에서 배우는 것

동네 도서관의 관리 프로그램에는 도서, 회원, 대출 기록처럼 여러 종류의 객체가 등장한다. 처음에는 종류마다 목록을 만들고 검색 코드를 작성해도 부담이 크지 않다. 그러나 같은 식별자 검색과 중복 검사가 여러 곳에 생기면 수정할 코드도 함께 늘어난다. 제네릭(generics)은 이런 공통 동작을 형식에 맞게 재사용하는 수단이다. 기본서에서 컬렉션을 사용할 때 적었던 Of T를 이번에는 직접 설계하는 클래스와 메서드에 적용한다.

이 장에서는 저장할 객체가 갖추어야 할 조건을 선언하고, 읽기와 쓰기 역할을 나누어 저장소를 만든다. 이어서 서로 상속 관계에 있는 형식을 사용하는 제네릭 인터페이스 사이에서 어떤 대입이 가능한지 살펴본다. 예제는 메모리에서만 동작하며 파일 저장, 대출 처리, 예약 정책은 다루지 않는다.

  • Class, New, 인터페이스 제약 조건이 허용하는 연산과 막아 주는 사용을 구분한다.
  • 형식 매개변수를 메서드에 선언하고 필요한 제약 조건을 붙인다.
  • Out과 In으로 읽기와 쓰기 인터페이스의 대입 범위를 설명한다.
  • 식별자 검색과 중복 검사를 공유하는 제네릭 저장소를 구현한다.
  • 콘솔 검사로 저장소와 변성 대입의 결과를 확인한다.

문제 상황

도서관 담당자가 새 도서를 등록한 뒤 도서 번호로 찾아 제목을 확인한다고 가정한다. 회원 관리에도 같은 요구가 있다. 회원 번호가 같은 객체를 두 번 등록해서는 안 되며, 등록되지 않은 번호를 조회했을 때의 처리도 정해야 한다. 도서와 회원의 속성은 다르지만, 식별자로 등록하고 찾는 절차는 같다.

모든 객체를 List(Of Object)에 넣으면 목록은 하나로 합칠 수 있다. 하지만 목록에서 꺼낸 객체가 도서인지 회원인지 확인하고 형식을 변환해야 한다. 식별자를 읽는 코드도 객체의 실제 형식에 의존한다. Option Strict On을 사용하면 Object에 있는 것처럼 식별자 속성을 호출할 수도 없다. 공통 저장소가 필요하다는 이유로 형식 정보를 버리면 사용 지점마다 확인 작업이 생긴다.

반대로 List(Of Book)을 그대로 사용하면 도서만 들어간다는 조건은 유지된다. 다만 목록 자체는 도서 번호가 양수인지, 이미 같은 번호가 있는지 알지 못한다. 저장소는 이런 등록 규칙을 한곳에 모으고, 사용자는 도서 형식 그대로 결과를 받게 해야 한다.

또 다른 요구도 있다. 화면 출력 코드는 도서의 세부 속성을 모두 알 필요 없이 식별자와 표시 이름만 읽으면 된다. 등록 기록을 출력하는 객체는 도서뿐 아니라 식별자가 있는 모든 객체를 받을 수 있다. 이처럼 실제 저장 형식과 사용하는 역할 사이에 차이가 있을 때 변성(variance)이 도움이 된다.

제약 조건으로 가능한 연산을 선언한다

형식 매개변수 T는 나중에 정해질 형식을 나타낸다. Repository(Of Book)에서는 T가 Book이고, 회원 저장소에서는 회원 클래스가 된다. 컴파일러는 제네릭 코드를 작성한 시점에 실제 형식을 모두 알 수 없으므로, 선언에 적힌 조건을 근거로 본문을 검사한다.

제약 조건(constraint)은 형식 매개변수에 사용할 수 있는 형식을 제한하는 선언이다. 다음 메서드는 참조 형식이며 IEntity를 구현하고 공개된 매개변수 없는 생성자로 만들 수 있는 형식만 받는다.

Public Function CreateEntity(Of T As {Class, IEntity, New})(
    id As Integer) As T

    Dim entity As New T()
    entity.Id = id
    Return entity
End Function

여러 조건은 중괄호 안에 쉼표로 나열한다. 여기서 Class는 참조 형식이라는 조건이지, 특정 이름의 클래스를 뜻하지 않는다. IEntity는 식별자 속성을 사용할 수 있게 한다. New는 본문에서 New T()를 호출할 근거가 된다. 생성자에서 객체를 만들었다고 해서 식별자가 자동으로 유효해지는 것은 아니다. 완성 코드에서는 생성하기 전에 번호를 검사한다.

제약 조건이 제네릭 코드에 제공하는 근거
조건요구하는 것사용할 수 있는 동작주의할 점
Class참조 형식참조로서 Nothing 여부 검사특정 속성이나 생성자를 보장하지 않는다.
IEntity해당 인터페이스 구현Id, DisplayName 접근선언하지 않은 도서 전용 속성은 사용할 수 없다.
New공개된 매개변수 없는 생성자New T()생성자 인수를 전달하는 기능은 아니다.
{Class, IEntity, New}나열한 조건 모두 충족생성 후 공통 속성 설정필요한 곳에만 조건을 붙인다.
형식 인수는 선언된 제약 조건을 모두 만족해야 생성 메서드에 들어갈 수 있다

제약 조건은 본문에 필요한 연산에서 거꾸로 정하는 것이 좋다. 이미 만들어진 객체를 보관하는 저장소라면 매개변수 없는 생성자가 없어도 된다. 따라서 완성 코드의 저장소에는 {Class, IEntity}만 붙이고, 객체를 만드는 메서드에만 New를 추가한다. 저장소에 불필요한 생성자 조건까지 붙이면 생성자에서 필수 값을 받는 클래스도 사용할 수 없게 된다.

인터페이스를 구현하는 구조체도 만들 수 있으므로 IEntity만으로는 참조 형식이라고 단정할 수 없다. 이번 저장소는 조회 실패를 Nothing으로 나타내고 객체 참조를 보관하므로 Class를 함께 요구한다. 값 형식을 다루는 저장소를 만들려면 조회 실패 표현과 변경 방식부터 다시 정해야 한다.

제네릭 메서드는 호출마다 형식을 정한다

클래스에 형식 매개변수를 선언하면 그 클래스의 인스턴스가 하나의 형식에 묶인다. 반면 제네릭 메서드는 호출마다 형식 인수를 정한다. 도서를 만드는 호출과 회원을 만드는 호출이 같은 메서드 정의를 사용할 수 있다는 뜻이다.

Dim book As Book = CreateEntity(Of Book)(101)

이 호출에서 Of Book은 형식 인수이고, 괄호 안의 101은 일반 인수다. 생성 메서드의 인수 목록에는 T를 추론할 정보가 없으므로 형식 인수를 직접 적는다. 반환값을 받을 변수의 형식만 보고 이 메서드의 T를 정할 수 있다고 기대해서는 안 된다.

완성 코드의 RequireEntity는 읽기 저장소에서 객체를 찾고, 없으면 예외를 발생시킨다. 이 메서드는 객체를 생성하지 않으므로 New 제약이 필요 없다. 조회한 결과에 대한 Nothing 검사와 공통 인터페이스 사용에 필요한 조건만 둔다. 예제 호출에서는 어떤 형식을 요구하는지 드러나도록 Of Book을 명시한다.

In과 Out으로 사용 방향을 표현한다

Book이 IEntity를 구현하더라도 List(Of Book)을 List(Of IEntity) 변수에 대입할 수는 없다. 만약 그 대입을 허용하면 대입받은 목록을 통해 회원 객체를 넣을 수 있다. 그러면 도서만 담는 원래 목록의 조건이 깨진다. 일반적인 제네릭 클래스는 형식 인수 사이에 관계가 있다는 이유만으로 서로 대입할 수 없다.

하지만 도서를 내보내기만 하는 인터페이스라면 이야기가 달라진다. 반환된 도서를 IEntity로 읽는 것은 가능하다. 이 방향을 공변성(covariance)이라고 하며 형식 매개변수 앞에 Out을 붙인다.

Public Interface IReadStore(Of Out T)
    Function FindById(id As Integer) As T
    Function GetAll() As IEnumerable(Of T)
End Interface

FindById는 T를 반환한다. GetAll의 반환 형식에 들어 있는 IEnumerable(Of T)도 공변성을 지원하므로 이 선언이 가능하다. 반면 이 인터페이스에 Sub Add(item As T)를 넣으면 T를 입력으로 받게 되어 Out의 조건에 어긋난다.

입력만 받는 역할에는 반공변성(contravariance)을 사용하며 In을 붙인다. 모든 IEntity를 받을 수 있는 객체라면 그중 하나인 Book도 받을 수 있다. 따라서 IWriteStore(Of IEntity)를 IWriteStore(Of Book) 변수에 대입할 수 있다.

Public Interface IWriteStore(Of In T)
    Sub Add(item As T)
End Interface

방향을 이름만으로 외우기보다 호출에서 어떤 값이 이동하는지 보는 편이 낫다. 읽기 역할은 구체적인 도서를 반환하고 호출자는 이를 공통 인터페이스로 받아 읽는다. 쓰기 역할은 도서를 입력받는 것으로 보이지만 실제 구현은 더 넓은 범위의 객체를 처리할 수 있다.

Out은 도서 읽기를 공통 객체 읽기로 넓히고 In은 공통 객체 쓰기를 도서 쓰기로 사용할 수 있게 한다
도서 형식과 공통 인터페이스 사이의 변성 대입
선언대입할 원래 참조대입받는 변수이유
Of Out TIReadStore(Of Book)IReadStore(Of IEntity)반환된 도서를 공통 인터페이스로 읽을 수 있다.
Of In TIWriteStore(Of IEntity)IWriteStore(Of Book)모든 공통 객체를 받는 구현은 도서도 받는다.
변성 선언 없음Repository(Of Book)Repository(Of IEntity)형식 인수 관계만으로는 대입할 수 없다.

Visual Basic에서 변성 형식 매개변수는 인터페이스와 델리게이트에 선언한다. 이번 장에서는 인터페이스만 사용한다. 저장소 클래스 자체에 Out이나 In을 붙이는 방식은 사용할 수 없다. 또한 변성 변환은 참조 형식 사이에서 적용되며, 값 형식의 박싱을 대신해 주지 않는다. IEnumerable(Of Integer)를 IEnumerable(Of Object)로 바로 대입할 수 없는 이유도 여기에 있다.

읽기 인터페이스를 얻었다고 해서 저장소의 객체가 변경 불가능해지는 것은 아니다. 인터페이스가 제한하는 것은 저장소를 통해 호출할 수 있는 멤버다. 반환된 객체 자체에 공개된 쓰기 속성이 있다면 호출자는 그 속성을 바꿀 수 있다. 변성 대입은 객체를 복사하거나 새로운 저장소를 만드는 작업도 아니다.

완성 코드

다음 내용을 콘솔 프로젝트의 Program.vb에 넣는다. 저장소는 등록 순서를 유지하는 목록을 사용하고, 검색할 때 식별자를 순서대로 비교한다. 작은 도서관 예제에서 동작을 드러내기 위한 선택이다. 목록의 요소 수에 따라 검색 비용이 늘어나는 점은 실제 적용 때 고려해야 한다.

Id는 등록 전에 설정하며 등록 뒤에는 바꾸지 않는 것으로 정한다. 이 인터페이스와 클래스가 그 규칙을 강제하지는 않는다. 예제의 생성 메서드에서 식별자를 설정할 수 있게 하면서 저장소의 범위를 작게 유지하기 위한 약속이다. 등록 이후 변경까지 허용하는 설계라면 식별자 변경 절차와 중복 검사를 별도로 마련해야 한다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections.Generic

Public Interface IEntity
    Property Id As Integer
    ReadOnly Property DisplayName As String
End Interface

Public Class Book
    Implements IEntity

    Public Property Id As Integer Implements IEntity.Id
    Public Property Title As String = "(제목 미입력)"

    Public ReadOnly Property DisplayName As String _
        Implements IEntity.DisplayName
        Get
            Return Title
        End Get
    End Property
End Class

Public Interface IReadStore(Of Out T)
    Function FindById(id As Integer) As T
    Function GetAll() As IEnumerable(Of T)
End Interface

Public Interface IWriteStore(Of In T)
    Sub Add(item As T)
End Interface

Public Class Repository(Of T As {Class, IEntity})
    Implements IReadStore(Of T), IWriteStore(Of T)

    Private ReadOnly items As New List(Of T)()

    Public Sub Add(item As T) Implements IWriteStore(Of T).Add
        If item Is Nothing Then
            Throw New ArgumentNullException(NameOf(item))
        End If

        If item.Id <= 0 Then
            Throw New ArgumentOutOfRangeException(
                NameOf(item), "식별자는 양수여야 한다.")
        End If

        If FindById(item.Id) IsNot Nothing Then
            Throw New InvalidOperationException(
                "같은 식별자가 이미 등록되어 있다.")
        End If

        items.Add(item)
    End Sub

    Public Function FindById(id As Integer) As T _
        Implements IReadStore(Of T).FindById

        For Each item As T In items
            If item.Id = id Then
                Return item
            End If
        Next

        Return Nothing
    End Function

    Public Function GetAll() As IEnumerable(Of T) _
        Implements IReadStore(Of T).GetAll

        Return items.ToArray()
    End Function
End Class

Public Class RegistrationLog
    Implements IWriteStore(Of IEntity)

    Public Sub Add(item As IEntity) _
        Implements IWriteStore(Of IEntity).Add

        If item Is Nothing Then
            Throw New ArgumentNullException(NameOf(item))
        End If

        Console.WriteLine(
            $"접수: {item.Id} / {item.DisplayName}")
    End Sub
End Class

Module Program
    Sub Main()
        Dim books As New Repository(Of Book)()

        Dim first As Book = CreateEntity(Of Book)(101)
        first.Title = "골목의 식물"
        books.Add(first)

        Dim second As Book = CreateEntity(Of Book)(102)
        second.Title = "작은 천문 관측"
        books.Add(second)

        Dim found As Book = RequireEntity(Of Book)(books, 101)
        Console.WriteLine($"조회: {found.Id} / {found.Title}")

        Dim bookReader As IReadStore(Of Book) = books
        Dim entityReader As IReadStore(Of IEntity) = bookReader

        Dim count As Integer = 0
        For Each entity As IEntity In entityReader.GetAll()
            Console.WriteLine(
                $"목록: {entity.Id} / {entity.DisplayName}")
            count += 1
        Next

        Dim entityWriter As IWriteStore(Of IEntity) =
            New RegistrationLog()
        Dim bookWriter As IWriteStore(Of Book) = entityWriter
        bookWriter.Add(found)

        Dim duplicateRejected As Boolean = False
        Try
            books.Add(first)
        Catch ex As InvalidOperationException
            duplicateRejected = True
        End Try

        Check(duplicateRejected, "중복 식별자 거부")

        Check(
            count = 2 AndAlso Object.ReferenceEquals(first, found),
            "조회와 등록 순서 목록")

        Check(books.FindById(999) Is Nothing, "없는 식별자 조회")
    End Sub

    Private Function CreateEntity(Of T As {Class, IEntity, New})(
        id As Integer) As T

        If id <= 0 Then
            Throw New ArgumentOutOfRangeException(
                NameOf(id), "식별자는 양수여야 한다.")
        End If

        Dim entity As New T()
        entity.Id = id
        Return entity
    End Function

    Private Function RequireEntity(Of T As {Class, IEntity})(
        source As IReadStore(Of T), id As Integer) As T

        If source Is Nothing Then
            Throw New ArgumentNullException(NameOf(source))
        End If

        Dim entity As T = source.FindById(id)
        If entity Is Nothing Then
            Throw New KeyNotFoundException(
                "요청한 식별자가 등록되어 있지 않다.")
        End If

        Return entity
    End Function

    Private Sub Check(condition As Boolean, name As String)
        If Not condition Then
            Throw New InvalidOperationException(
                "검사 실패: " & name)
        End If

        Console.WriteLine("검사 통과: " & name)
    End Sub
End Module

줄별 해설

Option Strict On은 형식이 맞지 않는 대입을 암시적 축소 변환으로 넘기지 않게 한다. Option Explicit On은 변수 선언을 요구하고, Option Infer On은 초기화식에서 지역 변수의 형식을 추론할 수 있게 한다. 예제에서는 제네릭 대입 관계를 읽기 쉽도록 대부분의 변수 형식을 직접 적었다.

IEntity의 Id는 모든 저장 대상의 공통 식별자다. DisplayName은 출력에 사용할 이름을 읽는 속성이다. Book은 두 멤버를 구현하고 도서 전용 속성인 Title도 갖는다. DisplayName의 반환문이 Title을 돌려주므로 공통 인터페이스를 사용하는 코드에서도 도서 제목을 출력할 수 있다.

IReadStore의 두 함수는 객체를 반환하는 역할만 맡는다. FindById는 하나를 찾고 GetAll은 열거할 수 있는 결과를 반환한다. 식별자를 전달하는 Integer 매개변수는 변성 대상인 T와 관계없으므로 Out 선언에 문제가 없다. IWriteStore는 T를 인수로 받는 Add만 선언한다.

Repository의 선언은 읽기와 쓰기 인터페이스를 동시에 구현한다. 이 클래스는 객체를 받아 저장하기도 하고 반환하기도 하므로 클래스의 형식 인수를 한 방향으로만 취급하지 않는다. 사용하는 쪽에서 필요한 인터페이스를 선택하면 같은 저장소를 읽기 역할 또는 쓰기 역할로 전달할 수 있다.

items는 외부에 공개하지 않은 목록이다. ReadOnly는 필드가 다른 목록을 가리키도록 다시 대입하는 일을 제한한다. 목록 안에 요소를 추가하는 동작까지 막지는 않으므로 items.Add(item)은 허용된다. 객체를 조회한 뒤 다시 돌려줄 때는 저장했던 참조가 그대로 사용된다.

Add의 검사는 순서에 의미가 있다. 먼저 Nothing을 검사해야 다음 줄에서 item.Id를 안전하게 읽을 수 있다. 이어서 번호의 범위를 확인하고 기존 번호를 검색한다. 모든 검사를 통과한 뒤에만 목록을 바꾸므로, 거부된 등록은 목록의 요소 수를 늘리지 않는다.

FindById는 목록을 돌면서 처음 일치한 객체를 반환한다. 끝까지 찾지 못하면 Nothing을 반환한다. 이 계약 때문에 호출자는 실패를 직접 검사하거나, 없을 때 예외를 발생시키는 RequireEntity를 선택할 수 있다. 두 방식은 실패 처리 정책이 다를 뿐 같은 조회 기능을 사용한다.

GetAll의 ToArray()는 List(Of T)의 인스턴스 메서드다. LINQ 확장 메서드를 사용한 것이 아니다. 호출 시점의 목록 요소를 별도 배열에 담아 반환하므로, 나중에 저장소에 도서를 추가해도 이미 반환한 배열의 길이는 바뀌지 않는다. 다만 배열 안의 객체 참조까지 복제하지는 않는다. 도서 제목을 변경하면 그 도서를 가리키는 다른 참조에서도 변경된 제목이 보인다.

RegistrationLog는 IWriteStore(Of IEntity)를 구현한다. 이 객체의 Add는 보관 대신 접수 내역을 출력한다. 인터페이스 선언만으로 실제 저장 방식이나 보관 기간까지 결정되는 것은 아니라는 점을 보여 준다. 실무에서 반드시 보관을 보장해야 한다면 이름과 계약을 그 요구에 맞게 더 구체적으로 정해야 한다.

Main에서 두 도서를 만든 뒤 등록하는 부분은 일반적인 저장소 사용이다. RequireEntity(Of Book)의 반환 형식은 Book이므로 별도의 형식 변환 없이 found.Title을 읽는다. 이어지는 entityReader 대입은 공변성을 보여 준다. 목록을 읽는 반복문은 도서 전용 속성 대신 IEntity에 선언된 멤버만 사용한다.

bookWriter 대입은 반공변성을 보여 준다. 모든 공통 객체를 받는 기록 객체를 도서 입력용 인터페이스로 사용한다. 대입받은 변수의 Add에는 도서를 전달해야 하지만, 실제 호출되는 구현은 RegistrationLog.Add다. 두 변성 예제 모두 형식 검사가 컴파일 시점에 이루어진다.

마지막 부분은 같은 객체를 다시 등록하여 중복이 거부되는지 확인한다. 기대한 예외를 받았을 때만 검사 변수를 바꾼다. Check는 조건이 거짓이면 프로그램을 예외로 끝내고, 참이면 고정된 문장을 출력한다. 별도의 테스트 프로젝트는 이 장에서 구성하지 않는다. 이후 테스트 프로젝트를 사용하더라도 중복 등록, 조회 실패, 반환된 객체의 관계처럼 관찰할 수 있는 동작을 검사한다는 기준은 같다.

실행 결과

macOS 또는 Linux 터미널에서 다음 명령으로 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 뒤 실행한다. .NET 10 SDK가 설치된 환경을 기준으로 한다.

dotnet new console -lang VB -f net10.0 -n LibraryGenerics
cd LibraryGenerics
dotnet run

프로그램이 출력하는 내용은 다음과 같다. 목록은 등록 순서대로 나오며 날짜, 난수, 실행 시간에 의존하는 값은 없다. 모든 검사를 통과하면 정상적으로 끝난다.

조회: 101 / 골목의 식물
목록: 101 / 골목의 식물
목록: 102 / 작은 천문 관측
접수: 101 / 골목의 식물
검사 통과: 중복 식별자 거부
검사 통과: 조회와 등록 순서 목록
검사 통과: 없는 식별자 조회

현재 콘솔 검사는 목록의 개수와 조회 결과의 참조 관계를 확인한다. 출력으로 등록 순서도 볼 수 있지만, 검사 조건이 각 위치의 식별자까지 비교하는 것은 아니다. 연습 문제에서는 반환된 목록을 직접 조사하여 순서와 실패 후 상태를 더 구체적으로 검사한다.

실무에서 자주 틀리는 것

공통 속성을 쓰면서 인터페이스 제약을 빠뜨린다

다음 코드는 T가 참조 형식이라는 사실만 선언했다. 참조 형식에는 식별자가 없는 클래스도 있으므로 Id 접근을 컴파일할 수 없다. 틀린 코드는 완성 프로그램에 추가하지 않는다.

Private Function ReadId(Of T As Class)(item As T) As Integer
    Return item.Id
End Function

공통 속성을 선언한 인터페이스를 제약에 추가한다. 이렇게 하면 호출할 수 있는 형식도 함께 제한된다. Nothing 검사는 속성 접근보다 먼저 수행한다.

Private Function ReadId(Of T As {Class, IEntity})(
    item As T) As Integer

    If item Is Nothing Then
        Throw New ArgumentNullException(NameOf(item))
    End If

    Return item.Id
End Function

Class만 있으면 New T()도 가능하다고 생각한다

참조 형식이라는 조건은 생성자 모양을 보장하지 않는다. 어떤 클래스는 문자열을 받는 생성자만 공개할 수 있다. 다음 선언만으로는 New T()를 사용할 수 없다.

Private Function MakeItem(Of T As Class)() As T
    Return New T()
End Function

이 메서드가 매개변수 없는 생성자를 필요로 한다면 New 조건을 추가한다. 호출할 클래스에도 그 조건을 만족하는 생성자가 있어야 한다. 도서 클래스처럼 생성자를 따로 선언하지 않은 경우에는 컴파일러가 기본 생성자를 제공한다.

Private Function MakeItem(Of T As {Class, New})() As T
    Return New T()
End Function

Out으로 선언한 형식을 입력으로 받는다

다음 인터페이스는 도서를 공통 객체의 읽기 인터페이스로 사용할 수 있게 하면서, 동시에 다른 공통 객체를 입력받을 여지를 만든다. 컴파일러는 Out T를 Add의 매개변수에 사용하는 선언을 거부한다.

Public Interface IBadStore(Of Out T)
    Function FindById(id As Integer) As T
    Sub Add(item As T)
End Interface

읽기와 쓰기 역할을 나누면 각 방향에 맞는 변성을 선언할 수 있다. 하나의 클래스가 두 인터페이스를 함께 구현하는 것은 가능하다. 두 인터페이스를 반드시 하나로 합쳐야 한다면 변성 표시를 제거하여 입력과 반환에 같은 T를 사용한다.

Public Interface IReadOnlyStore(Of Out T)
    Function FindById(id As Integer) As T
End Interface

Public Interface IInputStore(Of In T)
    Sub Add(item As T)
End Interface

반공변성 대입 방향을 뒤집는다

도서만 받는 구현이 회원까지 받을 수 있다고 보장할 수는 없다. 따라서 도서 쓰기 인터페이스를 모든 공통 객체의 쓰기 인터페이스로 넓히는 다음 대입은 허용되지 않는다.

Dim onlyBooks As IWriteStore(Of Book) =
    New Repository(Of Book)()

Dim allEntities As IWriteStore(Of IEntity) = onlyBooks

방향을 반대로 잡는다. 모든 공통 객체를 처리하는 구현을 도서용으로 사용하는 것은 가능하다. 이때 도서용 변수로는 회원을 전달할 수 없으므로 호출 지점의 형식 조건도 유지된다.

Dim allEntities As IWriteStore(Of IEntity) =
    New RegistrationLog()

Dim onlyBooks As IWriteStore(Of Book) = allEntities

한눈에 보기

제네릭 저장소를 설계할 때 확인할 항목
판단할 일선택예제의 적용
공통 멤버를 호출하는가인터페이스 제약을 붙인다.IEntity.Id로 검색한다.
본문에서 객체를 생성하는가New 제약을 붙인다.CreateEntity만 생성한다.
조회 실패를 참조 없음으로 나타내는가Class 제약과 실패 계약을 둔다.FindById가 Nothing을 반환한다.
형식 매개변수를 반환하는 역할인가Out을 검토한다.도서 읽기를 공통 객체 읽기로 사용한다.
형식 매개변수를 입력받는 역할인가In을 검토한다.공통 객체 입력을 도서 입력으로 사용한다.
받기도 하고 반환하기도 하는가역할을 나누거나 변성 없이 선언한다.저장소 클래스가 두 인터페이스를 구현한다.

제약 조건은 제네릭 코드가 어떤 연산을 해도 되는지 정한다. 변성은 그렇게 만들어진 인터페이스 참조를 어떤 형식의 변수로 사용할 수 있는지 정한다. 두 기능은 서로 다른 질문에 답한다. 제약 조건을 늘린다고 제네릭 클래스 사이의 대입이 자동으로 허용되지는 않으며, 변성을 선언한다고 객체의 유효성 검사가 대신 수행되지도 않는다.

다음 장에서는 메서드에 동작을 전달하는 방법을 살펴본다. 이번 저장소가 객체의 형식을 매개변수로 받았다면, 람다와 델리게이트를 사용하는 코드는 실행할 동작을 값으로 전달한다.

연습 문제

  1. IEntity를 구현하는 Member 클래스를 추가한다. 회원 이름은 Name 속성에 두고 표시 이름으로 반환한다. 회원 번호 201, 이름 “서윤”인 객체를 생성 메서드로 만들어 회원 저장소에 등록하고 조회하라.
  2. CreateEntity에서 New 제약을 제거하면 어느 문장이 컴파일되지 않는지 설명하라. 저장소 클래스에 같은 제약이 필요하지 않은 이유도 설명하라.
  3. IReadStore(Of Book)에서 IReadStore(Of IEntity)로 대입한 변수를 사용하여 Title을 직접 읽을 수 있는지 판단하라. 공통 인터페이스만으로 제목을 출력하는 코드를 작성하라.
  4. 완성 코드의 중복 등록 검사 뒤에 검사 두 개를 추가하라. 반환된 목록의 식별자가 101, 102 순서인지 확인하고, RequireEntity(Of Book)에 999를 전달하면 KeyNotFoundException이 발생하는지 확인하라.

정답과 해설

회원도 같은 저장소를 사용한다

다음 클래스를 Module Program 바깥에 추가한다. Book과 속성 이름은 다르지만 IEntity의 계약을 충족하므로 같은 저장소 정의를 사용할 수 있다.

Public Class Member
    Implements IEntity

    Public Property Id As Integer Implements IEntity.Id
    Public Property Name As String = "(이름 미입력)"

    Public ReadOnly Property DisplayName As String _
        Implements IEntity.DisplayName
        Get
            Return Name
        End Get
    End Property
End Class

다음 코드를 Main 안에 추가한다. 저장소와 생성 메서드의 형식 인수만 회원으로 바뀐다. 조회 결과가 Member이므로 Name을 직접 읽을 수 있다. 추가 출력은 회원: 201 / 서윤이다.

Dim members As New Repository(Of Member)()
Dim member As Member = CreateEntity(Of Member)(201)
member.Name = "서윤"
members.Add(member)

Dim foundMember As Member =
    RequireEntity(Of Member)(members, 201)

Console.WriteLine(
    $"회원: {foundMember.Id} / {foundMember.Name}")

생성하는 위치에만 생성자 제약이 필요하다

New를 제거하면 Dim entity As New T()가 컴파일되지 않는다. Class와 IEntity를 만족하는 클래스라도 공개된 매개변수 없는 생성자가 없을 수 있기 때문이다. 저장소는 전달받은 객체를 보관하며 내부에서 New T()를 호출하지 않는다. 저장소의 제약에서 생성자를 요구할 이유가 없다.

생성자 인수가 필요한 클래스를 다루려면 해당 클래스의 생성자로 외부에서 객체를 만든 뒤 저장소에 전달할 수 있다. 그 경우에도 식별자와 중복 검사는 저장소가 수행한다. 생성 방법을 재사용하는 문제와 등록 규칙을 재사용하는 문제를 따로 생각하면 제약 조건을 필요한 범위에 둘 수 있다.

대입받은 변수의 형식이 접근할 멤버를 정한다

IReadStore(Of IEntity)에서 반환받은 값은 코드상 IEntity다. 실제 객체가 도서여도 이 인터페이스에 없는 Title을 직접 읽을 수는 없다. 이번 계약에서는 DisplayName이 제목을 반환하므로 다음처럼 출력한다. Main 안에서 기존 bookReader를 사용한다.

Dim commonReader As IReadStore(Of IEntity) = bookReader
Dim entity As IEntity = commonReader.FindById(101)

If entity IsNot Nothing Then
    Console.WriteLine(entity.DisplayName)
End If

추가 출력은 골목의 식물이다. 도서 전용 멤버가 반드시 필요한 코드라면 처음부터 도서 읽기 인터페이스를 받도록 정하는 편이 의도를 잘 드러낸다. 공통 인터페이스를 받는 모든 코드에 도서 형식 변환을 넣으면 다른 종류의 객체를 처리하려던 목적이 줄어든다.

실패 뒤의 상태와 예외 계약을 검사한다

다음 코드를 Main의 중복 등록 검사 이후에 추가한다. 반환된 결과로 새 목록을 만들어 위치별 식별자와 개수를 확인한다. 이어서 찾지 못한 객체를 요구할 때 발생하는 예외를 확인한다.

Dim saved As New List(Of Book)(books.GetAll())

Check(
    saved.Count = 2 AndAlso
    saved(0).Id = 101 AndAlso
    saved(1).Id = 102,
    "중복 거부 뒤 개수와 순서")

Dim missingRejected As Boolean = False
Try
    Dim missing As Book = RequireEntity(Of Book)(books, 999)
    Check(missing IsNot Nothing, "필수 조회 반환값")
Catch ex As KeyNotFoundException
    missingRejected = True
End Try

Check(missingRejected, "필수 조회 실패 예외")

AndAlso는 앞 조건이 거짓이면 뒤 조건을 평가하지 않는다. 개수가 2가 아니면 인덱스로 요소를 읽지 않으므로 검사 자체에서 범위 오류가 발생하는 일을 피한다. 두 번째 검사에서는 예상한 예외 종류만 잡는다. 다른 예외가 발생하면 프로그램이 그대로 실패하여 원인을 드러낸다. 추가되는 통과 출력은 “중복 거부 뒤 개수와 순서”와 “필수 조회 실패 예외” 두 줄이다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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