Devin.KR

확장 메서드와 반복기 - Iterator 와 Yield

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서 LINQ 쿼리를 만들었다고 해서 데이터가 곧바로 처리되는 것은 아니라는 점을 살펴보았다. 이번에는 그 지연 실행을 직접 만드는 방법을 다룬다. 작은 동네 도서관에서 대출 목록을 순회하며 연체 대출만 골라 내고, 필요한 수만큼 예약 번호를 생성하는 코드를 작성한다. 데이터를 사용하는 쪽에서는 평소처럼 For Each를 쓰지만, 데이터를 제공하는 쪽에서는 값이 요청될 때마다 하나씩 계산한다.

확장 메서드(extension method)는 기존 형식의 인스턴스 메서드처럼 호출할 수 있는 메서드다. 반복기(iterator)는 다음 값을 요청받을 때 실행을 이어 가는 함수다. 두 기능을 연결하면 도서관 업무에 맞는 순회 연산을 만들면서도 IEnumerable(Of T)를 사용하는 기존 코드와 자연스럽게 조합할 수 있다.

  • Extension 특성을 붙인 메서드를 선언하고, 첫 번째 매개변수가 호출 대상과 어떻게 연결되는지 설명한다.
  • Iterator 함수와 Yield로 값을 하나씩 내보내며, 실행이 시작되고 멈추는 위치를 구분한다.
  • IEnumerable(Of T)를 직접 구현하고, 실제 순회 상태는 반복기가 만든 열거자에 맡긴다.
  • 끝이 없는 시퀀스를 만들고, Take로 소비 범위를 제한한다.
  • 콘솔 검사 함수로 지연 실행, 재열거, 순회 종료에 따른 정리를 확인한다.

문제 상황

동네 도서관은 매일 대출 목록에서 연체된 책을 찾아 안내 목록을 만든다. 담당자는 화면에서 처음 두 건을 먼저 확인하고, 필요할 때 전체 목록을 펼친다. 지금까지는 연체 대출을 모두 새 목록에 넣은 다음 화면에 두 건만 표시했다. 대출 데이터가 늘어나면 두 건을 보여 주기 위해 나머지 대출까지 검사하게 된다.

업무 규칙도 여러 위치에 흩어져 있다. 어떤 화면은 연체 일수가 0보다 큰지 직접 비교하고, 다른 화면은 연체료 계산식을 다시 작성한다. 대출 형식에 모든 화면용 기능을 추가하면 형식 자체가 커진다. 반대로 매번 별도 도우미 함수를 호출하면 대출 목록을 읽는 흐름이 끊겨 보인다. 이 장에서는 연체 판별과 연체료 계산을 확장 메서드로 표현한다.

예약 번호에는 다른 요구가 있다. 미리 수천 개를 저장하는 대신 요청받는 만큼만 번호를 만들고 싶다. 끝이 없는 생성 규칙을 정의하는 일과 실제 화면에 몇 개를 표시하는 일은 분리해야 한다. 생성 규칙에 끝이 없더라도 이번 콘솔 프로그램은 다섯 개만 읽고 종료한다.

예제의 대출 데이터와 연체 일수는 코드에 고정한다. 현재 날짜를 읽거나 실제 파일에 접근하지 않는다. 따라서 어느 운영체제에서 실행해도 결과가 같으며, 출력만 비교해도 순회 동작을 확인할 수 있다. 여기서 다루는 번호는 순회 연습용이다. 실제 예약 번호를 영속적으로 발급하고 중복을 방지하는 기능까지 뜻하지는 않는다.

Extension 특성으로 업무 연산 붙이기

Visual Basic의 확장 메서드는 Module 안에 선언하며, System.Runtime.CompilerServices 이름 공간의 Extension 특성을 붙인다. 첫 번째 매개변수는 확장의 대상이다. 예를 들어 첫 번째 매개변수가 Loan이면 Loan 값에 대해 loan.LateFee(500D)처럼 호출할 수 있다. 이때 loan이 첫 번째 매개변수로 전달되고, 괄호 안의 500D는 두 번째 매개변수로 전달된다.

<Extension()>
Public Function LateFee(
    loan As Loan,
    dailyRate As Decimal
) As Decimal
    Return loan.OverdueDays * dailyRate
End Function

이 선언이 Loan 클래스의 소스나 내부 구조를 바꾸지는 않는다. 확장 메서드는 바깥에서 제공하는 호출 문법이며, 대상 형식의 비공개 멤버에 접근할 권한을 얻지 않는다. Loan이 공개한 OverdueDays 속성만 사용한다. 같은 호출에 적용 가능한 인스턴스 메서드가 있으면 인스턴스 메서드가 우선한다. 따라서 이미 있는 메서드와 이름 및 인수 구성이 겹치지 않도록 업무 의도를 드러내는 이름을 선택한다.

다른 이름 공간에 확장 모듈을 두었다면 호출하는 파일에서 그 이름 공간을 Imports로 가져와야 한다. 완성 코드에서는 클래스와 모듈을 같은 파일의 같은 이름 공간에 두므로 별도의 업무용 Imports가 필요하지 않다. Extension 특성의 이름을 해석하기 위한 Imports는 명시한다.

확장 대상은 단일 객체에 한정되지 않는다. 첫 번째 매개변수를 IEnumerable(Of Loan)으로 선언하면 배열, 목록, 직접 만든 대출 목록에 같은 연산을 적용할 수 있다. 특정 저장 방식보다 순회 계약에 의존하는 것이다. 완성 코드의 OverdueLoans는 대출 목록에서 연체된 항목만 내보내며, 결과를 다시 IEnumerable(Of Loan)으로 돌려준다.

확장 메서드의 선언과 호출이 연결되는 방식
선언 요소예제호출에서의 역할
선언 위치Module LibraryExtensions외부에서 업무 연산을 제공한다.
특성<Extension()>확장 메서드임을 표시한다.
첫 매개변수source As IEnumerable(Of Loan)source.OverdueLoans()의 호출 대상이다.
나머지 매개변수dailyRate As DecimalLateFee(500D)의 괄호 안 인수다.

확장 메서드 자체가 지연 실행을 보장하지는 않는다. 평범한 Function 안에서 새 목록을 채운 뒤 반환하면 호출하는 동안 작업이 끝난다. 반면 Iterator Function으로 선언하면 순회가 시작될 때 본문이 실행된다. 확장 호출 문법과 반복기의 실행 방식은 각각의 기능이며, 필요할 때 함께 사용한다.

Iterator와 Yield가 순회를 만드는 방식

Iterator Function 안의 Yield는 현재 값을 소비자에게 전달하고 실행을 잠시 멈춘다. 다음 값을 요청받으면 이전 Yield 다음 위치에서 실행을 이어 간다. Return처럼 함수 전체를 끝내는 문장이 아니다. 반복문의 현재 위치와 지역 변수 등 이어서 실행하는 데 필요한 상태는 컴파일러가 만든 열거자(enumerator)에 보관된다.

소비자는 보통 이 열거자를 직접 조작하지 않는다. For Each가 GetEnumerator로 열거자를 얻고, MoveNext로 다음 값이 있는지 확인한 뒤 Current로 값을 읽는다. MoveNext가 False를 반환하면 순회가 끝난다. 순회를 마쳤거나 중간에 빠져나오면 For Each가 열거자를 정리한다. Iterator는 이 계약에 맞는 상태 관리 코드를 대신 만들어 준다.

Public Iterator Function OverdueCore(
    source As IEnumerable(Of Loan)
) As IEnumerable(Of Loan)
    For Each loan As Loan In source
        If loan.OverdueDays > 0 Then
            Yield loan
        End If
    Next
End Function

함수를 호출하여 반환값을 변수에 넣는 것만으로는 위 For Each가 시작되지 않는다. 첫 번째 MoveNext가 들어와야 첫 대출을 읽는다. 연체되지 않은 대출은 Yield를 만나지 않으므로 같은 MoveNext 호출 안에서 다음 대출로 넘어간다. 소비자가 요청하는 값의 개수와 원본에서 검사하는 항목의 개수는 다를 수 있다.

반복기는 MoveNext 요청을 받으면 실행을 이어 가고 Yield에서 값을 전달하며 멈춘다

완성 코드의 원본 순서는 연체 3일, 연체 0일, 연체 2일, 연체 7일이다. 연체 대출 두 건을 얻으려면 첫 번째부터 세 번째까지 읽어야 한다. Take(2)는 두 번째 결과를 얻은 뒤 상위 순회를 종료하므로 네 번째 대출까지 검사하지 않는다. 이것이 결과를 모두 목록에 담은 뒤 앞부분을 자르는 방식과 다른 점이다.

반복기 본문 안의 인수 검사도 지연된다. 호출 직후 잘못된 인수를 거절하고 싶다면 일반 Function이 인수를 검사하고 별도의 Iterator Function을 반환하도록 나눈다. 완성 코드의 OverdueLoans는 이 구조를 사용한다. source가 Nothing이면 호출 시점에 예외를 던지고, 정상 입력의 실제 순회는 OverdueCore에 맡긴다.

Yield를 포함하는 Try 블록에는 Catch를 함께 둘 수 없다. 다만 Try와 Finally를 사용하여 순회 종료 시 정리를 표현할 수 있다. 완성 코드에서는 이를 이용해 종료된 순회 횟수를 기록한다. 실제 자원을 다룰 때에도 정리 코드는 모든 항목을 읽는 경우뿐 아니라 중간 종료까지 고려해야 한다.

IEnumerable 직접 구현과 끝이 없는 시퀀스

IEnumerable(Of T)는 값을 저장하는 자료구조 그 자체가 아니다. 특정 형식의 값을 순서대로 읽을 수 있다는 계약이다. 배열과 List(Of T)가 이 계약을 제공하지만, 직접 만든 클래스도 구현할 수 있다. 완성 코드의 LoanCatalog는 배열을 내부에 보관하면서 IEnumerable(Of Loan)을 구현한다.

이 인터페이스를 구현할 때는 형식이 있는 GetEnumerator와 비제네릭 IEnumerable의 GetEnumerator를 모두 연결한다. 형식이 있는 메서드는 IEnumerator(Of Loan)을 반환한다. 비제네릭 메서드는 이를 다시 사용하여 IEnumerator로 반환한다. 후자는 인터페이스를 통해 호출되도록 Private로 선언해도 된다.

직접 구현한다는 말이 MoveNext와 Current의 상태 전환까지 손으로 작성해야 한다는 뜻은 아니다. 이 예제에서는 공개 순회 계약은 LoanCatalog가 구현하고, 실제 열거자는 내부 Iterator Function이 만든다. GetEnumerator가 호출될 때마다 내부 반복기로부터 새 열거자를 얻으므로 순회 위치가 서로 분리된다.

대출 목록 순회에서 각 구성 요소가 맡는 책임
구성 요소책임완성 코드의 위치
IEnumerable(Of Loan)새 순회를 시작할 입구를 제공한다.LoanCatalog의 Implements 선언
GetEnumerator이번 순회의 열거자를 반환한다.공개 메서드와 비제네릭 연결 메서드
Iterator Function항목과 다음 실행 위치를 관리한다.EnumerateLoans와 OverdueCore
소비자읽을 개수와 종료 시점을 정한다.Take, ToArray, For Each

예제의 VisitedCount는 원본에서 실제로 내보낸 항목 수를 기록한다. TraversalsClosed는 시작된 순회가 종료되거나 정리될 때 증가한다. 이 속성들은 실행 흐름을 관찰하기 위한 장치다. 서로 다른 순회의 열거자는 독립적이지만, 두 카운터는 같은 LoanCatalog 인스턴스에 누적된다. 일반 업무 코드에서는 이런 관찰용 상태가 꼭 필요한지 따로 판단한다.

같은 시퀀스를 다시 열거하면 내부 함수도 다시 실행된다. Iterator가 결과를 기억해 두었다가 재사용하는 것은 아니다. 따라서 원본이 바뀌거나 순회 중 다른 작업이 일어나면 재열거의 결과나 비용도 달라질 수 있다. 완성 코드에서는 원본을 고정하고, 각 실험 사이에 카운터만 초기화하여 재실행 여부를 분명하게 보여 준다.

예약 번호 생성기는 종료 조건이 없는 Do 반복문을 사용한다. Yield로 번호를 내보낸 다음 1을 더한다. 고정 크기 정수인 Integer로 계속 증가시키면 형식의 범위를 넘는다. 여기서는 기본 라이브러리의 BigInteger를 사용한다. 논리적인 끝이 없다는 뜻이지 실행 시간과 메모리의 제약까지 없어진다는 뜻은 아니다.

끝이 없는 번호 생성기도 Take로 소비 개수를 제한하면 유한한 결과를 만들고 종료한다

끝이 없는 시퀀스에 ToArray나 Count를 바로 적용하면 끝을 기다리게 된다. 전체를 알아야 하는 정렬 역시 이 생성기에 그대로 사용할 수 없다. 먼저 Take(5)처럼 종료 범위를 정하고 나서 배열로 바꾼다. 조건 기반 종료를 선택할 때는 그 조건이 실제로 종료 지점에 도달하는지도 확인해야 한다.

언어 규칙을 더 확인하려면 확장 메서드 설명과 반복기 설명을 참고할 수 있다. 아래 프로그램은 도서관의 대출과 예약 번호라는 상황에 맞추어 작성한 예제다.

완성 코드

새 Visual Basic 콘솔 프로젝트의 Program.vb를 다음 내용으로 교체한다. 모든 선언을 한 파일에 둔다. Option Strict On을 사용하며, 필요한 이름 공간을 파일에 명시한다. 외부 패키지는 사용하지 않는다.

Option Strict On
Option Explicit On
Option Infer On

Imports System
Imports System.Collections
Imports System.Collections.Generic
Imports System.Globalization
Imports System.Linq
Imports System.Numerics
Imports System.Runtime.CompilerServices

Module Program
    Sub Main()
        Dim catalog As New LoanCatalog(
            New Loan() {
                New Loan("골목의 나무", 3),
                New Loan("작은 별 지도", 0),
                New Loan("비 오는 서가", 2),
                New Loan("오래된 우체통", 7)
            })

        Dim overdue As IEnumerable(Of Loan) = catalog.OverdueLoans()

        Check(catalog.VisitedCount = 0, "쿼리 생성의 지연 실행")
        Console.WriteLine(
            "쿼리 생성 후 방문: " &
            catalog.VisitedCount.ToString(CultureInfo.InvariantCulture))

        Dim firstTwo As Loan() = overdue.Take(2).ToArray()

        Check(firstTwo.Length = 2, "앞의 두 건")
        Check(firstTwo(0).Title = "골목의 나무", "첫 대출")
        Check(firstTwo(1).Title = "비 오는 서가", "둘째 대출")
        Check(catalog.VisitedCount = 3, "세 항목만 방문")
        Check(catalog.TraversalsClosed = 1, "중간 종료의 정리")

        Console.WriteLine("앞의 두 연체 대출")
        For Each loan As Loan In firstTwo
            Dim fee As Decimal = loan.LateFee(500D)
            Console.WriteLine(
                loan.Title & " | " &
                loan.OverdueDays.ToString(CultureInfo.InvariantCulture) &
                "일 | " &
                fee.ToString("0", CultureInfo.InvariantCulture) & "원")
        Next
        Console.WriteLine(
            "방문: " &
            catalog.VisitedCount.ToString(CultureInfo.InvariantCulture) &
            ", 정리: " &
            catalog.TraversalsClosed.ToString(CultureInfo.InvariantCulture))

        catalog.ResetCounters()
        Dim allOverdue As Loan() = overdue.ToArray()

        Check(allOverdue.Length = 3, "전체 연체 대출")
        Check(catalog.VisitedCount = 4, "재열거의 전체 방문")
        Check(catalog.TraversalsClosed = 1, "전체 순회의 정리")
        Check(allOverdue(2).LateFee(500D) = 3500D, "연체료")

        Console.WriteLine(
            "다시 열거한 연체 대출: " &
            allOverdue.Length.ToString(CultureInfo.InvariantCulture) & "건")
        Console.WriteLine(
            "방문: " &
            catalog.VisitedCount.ToString(CultureInfo.InvariantCulture) &
            ", 정리: " &
            catalog.TraversalsClosed.ToString(CultureInfo.InvariantCulture))

        Dim numbers As BigInteger() =
            ReservationNumbers(New BigInteger(100)).Take(5).ToArray()

        Check(numbers.Length = 5, "예약 번호 개수")
        Check(numbers(4) = New BigInteger(104), "예약 번호 마지막 값")

        Dim labels As IEnumerable(Of String) =
            numbers.Select(
                Function(number As BigInteger) As String
                    Return number.ToString(CultureInfo.InvariantCulture)
                End Function)

        Console.WriteLine("예약 번호: " & String.Join(", ", labels))
        Console.WriteLine("검사: 모두 통과")
    End Sub

    Private Iterator Function ReservationNumbers(
        start As BigInteger
    ) As IEnumerable(Of BigInteger)
        Dim current As BigInteger = start
        Do
            Yield current
            current += BigInteger.One
        Loop
    End Function

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

Public Class Loan
    Public ReadOnly Property Title As String
    Public ReadOnly Property OverdueDays As Integer

    Public Sub New(title As String, overdueDays As Integer)
        If String.IsNullOrWhiteSpace(title) Then
            Throw New ArgumentException("제목이 필요하다.", NameOf(title))
        End If
        If overdueDays < 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(overdueDays))
        End If

        Me.Title = title
        Me.OverdueDays = overdueDays
    End Sub
End Class

Public Class LoanCatalog
    Implements IEnumerable(Of Loan)

    Private ReadOnly _loans As Loan()

    Private _visitedCount As Integer

    Public Property VisitedCount As Integer
        Get
            Return _visitedCount
        End Get
        Private Set(value As Integer)
            _visitedCount = value
        End Set
    End Property

    Private _traversalsClosed As Integer

    Public Property TraversalsClosed As Integer
        Get
            Return _traversalsClosed
        End Get
        Private Set(value As Integer)
            _traversalsClosed = value
        End Set
    End Property

    Public Sub New(loans As IEnumerable(Of Loan))
        If loans Is Nothing Then
            Throw New ArgumentNullException(NameOf(loans))
        End If

        _loans = loans.ToArray()

        For Each loan As Loan In _loans
            If loan Is Nothing Then
                Throw New ArgumentException(
                    "대출 항목은 Nothing일 수 없다.", NameOf(loans))
            End If
        Next
    End Sub

    Public Sub ResetCounters()
        VisitedCount = 0
        TraversalsClosed = 0
    End Sub

    Public Function GetEnumerator() As IEnumerator(Of Loan) _
        Implements IEnumerable(Of Loan).GetEnumerator
        Return EnumerateLoans().GetEnumerator()
    End Function

    Private Function GetUntypedEnumerator() As IEnumerator _
        Implements IEnumerable.GetEnumerator
        Return GetEnumerator()
    End Function

    Private Iterator Function EnumerateLoans() As IEnumerable(Of Loan)
        Try
            For Each loan As Loan In _loans
                VisitedCount += 1
                Yield loan
            Next
        Finally
            TraversalsClosed += 1
        End Try
    End Function
End Class

Public Module LibraryExtensions
    <Extension()>
    Public Function OverdueLoans(
        source As IEnumerable(Of Loan)
    ) As IEnumerable(Of Loan)
        If source Is Nothing Then
            Throw New ArgumentNullException(NameOf(source))
        End If

        Return OverdueCore(source)
    End Function

    Private Iterator Function OverdueCore(
        source As IEnumerable(Of Loan)
    ) As IEnumerable(Of Loan)
        For Each loan As Loan In source
            If loan.OverdueDays > 0 Then
                Yield loan
            End If
        Next
    End Function

    <Extension()>
    Public Function LateFee(
        loan As Loan,
        dailyRate As Decimal
    ) As Decimal
        If loan Is Nothing Then
            Throw New ArgumentNullException(NameOf(loan))
        End If
        If dailyRate < 0D Then
            Throw New ArgumentOutOfRangeException(NameOf(dailyRate))
        End If

        Return loan.OverdueDays * dailyRate
    End Function
End Module

줄별 해설

파일 첫머리의 Option Strict On은 암시적인 축소 변환과 늦은 바인딩 등을 제한한다. Option Explicit On은 변수 선언을 요구하며, Option Infer On은 초기값으로부터 지역 변수의 형식을 추론할 수 있게 한다. 예제는 중요한 순회 경계에서 Loan(), IEnumerable(Of Loan), IEnumerator(Of Loan) 같은 형식을 직접 적어 반환 계약을 드러낸다.

Imports System.Collections는 비제네릭 IEnumerator와 IEnumerable을 위해 필요하다. System.Collections.Generic은 형식이 있는 순회 인터페이스를 제공한다. System.Linq는 Take, ToArray, Select를 사용하기 위해 가져온다. System.Numerics는 BigInteger를, System.Runtime.CompilerServices는 Extension 특성을 제공한다.

Main의 New LoanCatalog 부분은 네 대출을 고정된 순서로 만든다. 연체 일수가 0인 두 번째 항목을 사이에 배치하여 결과 두 건을 얻는 데 원본 세 건을 읽는다는 사실을 보여 준다. LoanCatalog 생성자는 입력을 배열로 복사한다. 이 복사는 생성 시점에 입력을 모두 읽지만, 이후 대출 목록을 사용하는 순회는 따로 지연된다. 모든 단계가 지연된다고 해석하지 않아야 한다.

Dim overdue 줄은 연체 목록을 만드는 방법을 받아 둔다. 이 시점의 VisitedCount는 0이다. 이어지는 Check가 그 조건을 확인한다. 관찰용 카운터는 LoanCatalog의 반복기에서만 증가하므로 생성자의 배열 복사 횟수를 나타내지는 않는다.

overdue.Take(2).ToArray()는 실제 소비를 시작한다. ToArray가 결과 배열을 만들기 위해 Take의 열거자를 진행시키고, Take는 OverdueCore에 값을 요청한다. OverdueCore는 원본 목록을 읽다가 연체 대출을 만나면 Yield한다. 두 결과를 얻은 뒤에는 원본 순회까지 정리되어 TraversalsClosed가 1이 된다.

firstTwo를 출력하는 For Each는 이미 만들어진 배열을 읽는다. 따라서 대출 목록을 다시 순회하지 않고 VisitedCount도 증가하지 않는다. LateFee의 500D에서 D는 Decimal 리터럴을 나타낸다. 연체 일수에 이 단가를 곱하며, 출력에는 문화권에 따라 달라지는 구분 기호가 들어가지 않도록 형식을 지정한다.

ResetCounters는 두 번째 실험을 읽기 쉽게 만들기 위한 메서드다. 진행 중인 열거자를 처음으로 되돌리는 기능은 아니다. 이 예제에서는 첫 순회를 끝낸 후에만 호출한다. 뒤의 overdue.ToArray()는 같은 시퀀스를 새로 열거하므로 원본 네 건을 다시 읽고 연체 대출 세 건을 얻는다.

ReservationNumbers 호출도 번호를 미리 만들지 않는다. Take(5).ToArray()가 다섯 번의 결과를 요구한다. 첫 값은 100이고, 다음 MoveNext마다 덧셈 후 다음 Yield에 도달한다. 마지막 값 104를 받은 뒤 정리되므로 다음 번호를 출력할 작업은 진행하지 않는다. Select는 만들어진 번호 배열을 문자열로 바꾸어 String.Join에 전달한다.

Check는 조건이 False일 때 검사 이름을 포함한 예외를 던진다. 성공할 때마다 줄을 출력하지 않고 마지막에 한 줄만 출력한다. 따라서 정상 출력은 일정하며, 요구한 순회 동작이 어긋나면 프로그램이 성공 문구에 도달하지 못한다. 별도의 테스트 프로젝트 구성은 이 장의 예제를 실행하는 데 필요하지 않다.

Loan의 생성자는 제목과 연체 일수를 검사한다. 이를 통해 업무 연산이 빈 제목이나 음수 일수를 처리하는 경우를 줄인다. LateFee에서는 호출자가 제공하는 단가를 별도로 검사한다. 확장 메서드는 형식 바깥에서 호출되며 Nothing도 첫 인수로 전달될 수 있으므로 대상 검사 역시 필요하다.

LoanCatalog의 GetEnumerator는 EnumerateLoans의 결과에서 열거자를 얻는다. 이 호출만으로 VisitedCount가 증가하지는 않는다. 실제 첫 MoveNext가 들어오면 Try 안의 For Each가 시작된다. VisitedCount 증가가 Yield보다 앞에 있으므로 값 하나를 소비자에게 전달한 시점에 방문 수가 이미 반영되어 있다.

Finally는 시작된 순회가 끝까지 진행되거나 열거자가 정리될 때 실행된다. 전혀 진행하지 않은 열거자를 만들었다는 이유만으로 본문의 Finally가 실행되는 것으로 이해하면 안 된다. 이 예제에서 카운터는 실제로 시작된 두 순회의 정리를 확인한다. 바깥 OverdueCore의 For Each가 안쪽 열거자를 관리하므로 중간 종료도 원본까지 전달된다.

마지막 확장 모듈은 빠른 인수 검사와 지연 순회를 나눈다. OverdueLoans는 일반 Function이므로 Nothing 검사를 즉시 수행한다. OverdueCore는 Iterator Function이므로 대출을 읽는 작업을 나중에 수행한다. 이 구분은 예외가 발생하는 시점을 호출자에게 일정하게 제공하는 데 도움이 된다.

실행 결과

.NET 10 SDK가 설치된 macOS 또는 Linux에서 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 뒤 프로젝트 디렉터리에서 실행한다.

dotnet new console -lang VB -f net10.0 -o LibraryIterators
cd LibraryIterators
dotnet run

dotnet run으로 실행한 프로그램의 예상 출력은 다음과 같다. 첫 실험은 세 항목에서 연체 두 건을 얻으며, 두 번째 실험은 네 항목 전체를 다시 읽는다.

쿼리 생성 후 방문: 0
앞의 두 연체 대출
골목의 나무 | 3일 | 1500원
비 오는 서가 | 2일 | 1000원
방문: 3, 정리: 1
다시 열거한 연체 대출: 3건
방문: 4, 정리: 1
예약 번호: 100, 101, 102, 103, 104
검사: 모두 통과

프로그램은 예약 번호 다섯 개를 읽은 뒤 정상 종료한다. 번호 생성기에 종료 조건이 없다는 사실과 실행 프로그램이 종료한다는 사실은 모순되지 않는다. 종료 범위를 소비자가 지정했기 때문이다.

실무에서 자주 틀리는 것

확장 대상도 호출 괄호 안에 다시 넣는다

LateFee의 선언에는 두 매개변수가 있지만, 인스턴스 모양으로 호출하면 첫 번째 매개변수는 점 왼쪽의 대상이 채운다. 다음 호출은 대상 대출을 중복 전달하므로 이 예제의 선언과 맞지 않는다.

' 틀린 코드
Dim fee As Decimal = loan.LateFee(loan, 500D)

확장 호출에서는 단가만 전달한다. 모듈 이름을 통해 일반 함수처럼 호출할 때는 대상도 괄호 안에 명시한다. 두 호출의 계산 결과는 같다.

' 고친 코드
Dim fee As Decimal = loan.LateFee(500D)

' 일반 함수 호출 형태
Dim sameFee As Decimal = LibraryExtensions.LateFee(loan, 500D)

반복기 안의 검사가 호출 즉시 실행된다고 생각한다

다음 함수는 순회가 시작된 뒤에야 Nothing 검사를 수행한다. 반환값을 저장하는 줄에서 예외가 발생하기를 기대한 코드라면 검사 위치가 요구와 맞지 않는다.

' 틀린 코드: 호출 즉시 검사한다는 의도와 다르다.
Private Iterator Function CheckedLoans(
    source As IEnumerable(Of Loan)
) As IEnumerable(Of Loan)
    If source Is Nothing Then
        Throw New ArgumentNullException(NameOf(source))
    End If

    For Each loan As Loan In source
        Yield loan
    Next
End Function

일반 Function에서 검사한 뒤 내부 반복기를 반환한다. 아래 두 함수는 Module 안에 함께 선언하는 예다.

' 고친 코드
Private Function CheckedLoans(
    source As IEnumerable(Of Loan)
) As IEnumerable(Of Loan)
    If source Is Nothing Then
        Throw New ArgumentNullException(NameOf(source))
    End If

    Return CheckedLoansCore(source)
End Function

Private Iterator Function CheckedLoansCore(
    source As IEnumerable(Of Loan)
) As IEnumerable(Of Loan)
    For Each loan As Loan In source
        Yield loan
    Next
End Function

끝이 없는 시퀀스를 먼저 배열로 바꾼다

ToArray는 원본의 끝까지 읽는다. 아래 코드는 배열을 완성하지 못하므로 뒤의 Take에 도달하지 않는다. 생성한 값을 계속 보관하려 하므로 메모리 사용도 늘어난다.

' 틀린 코드
Dim numbers As BigInteger() =
    ReservationNumbers(New BigInteger(100)).ToArray().Take(5).ToArray()

순서를 바꾸어 Take가 먼저 소비량을 제한하게 한다. 앞부분만 필요한 다른 연산에도 종료 가능한 범위를 먼저 정하는 습관을 적용한다.

' 고친 코드
Dim numbers As BigInteger() =
    ReservationNumbers(New BigInteger(100)).Take(5).ToArray()

열거자를 직접 얻고 정리하지 않는다

직접 MoveNext를 호출할 때는 열거자의 수명도 직접 관리해야 한다. 아래 코드는 한 값만 읽고 열거자를 정리하지 않아 반복기의 Finally 실행 시점을 보장하지 못한다.

' 틀린 코드
Dim reader As IEnumerator(Of Loan) = catalog.GetEnumerator()
If reader.MoveNext() Then
    Console.WriteLine(reader.Current.Title)
End If

Using은 해당 블록을 빠져나갈 때 Dispose를 호출한다. 보통은 For Each를 쓰는 편이 간결하지만, 직접 조작할 필요가 있다면 이렇게 수명을 제한한다.

' 고친 코드
Using reader As IEnumerator(Of Loan) = catalog.GetEnumerator()
    If reader.MoveNext() Then
        Console.WriteLine(reader.Current.Title)
    End If
End Using

IEnumerator의 Reset을 호출해서 재열거하려고 하는 것도 피한다. 반복기가 만든 열거자는 Reset을 지원하지 않는다. 새 순회가 필요하면 시퀀스에서 GetEnumerator를 다시 호출한다.

한눈에 보기

확장 메서드와 반복기를 사용할 때 확인할 기준
기능핵심 동작확인할 점
Extension첫 매개변수를 호출 대상으로 연결한다.Module 선언과 이름 공간의 범위를 확인한다.
Iterator값을 요청할 때 본문 실행을 진행한다.호출 시점과 순회 시점을 구분한다.
Yield현재 값을 내보내고 실행을 멈춘다.다음 요청은 Yield 다음에서 이어진다.
IEnumerable(Of T)순회마다 열거자를 제공한다.비제네릭 인터페이스 연결도 구현한다.
재열거반복기 본문을 새로 실행한다.계산 비용과 부수 효과가 반복될 수 있다.
끝이 없는 시퀀스종료 없이 다음 값을 생성한다.전체 소비 전에 유한한 범위를 지정한다.

대출 목록에는 확장 메서드로 업무 의미를 붙이고, 반복기로 필요한 값만 내보냈다. 직접 구현한 순회 계약은 저장 방식과 소비 방식을 연결한다. 다음 장에서는 값을 순회하는 이 흐름과 별개로, 작업의 완료를 기다리는 비동기 프로그래밍을 다룬다.

연습 문제

  1. 카운터가 0인 새 LoanCatalog에서 catalog.OverdueLoans().Take(1).ToArray()를 실행한다. 결과 개수, VisitedCount, TraversalsClosed를 예상하고 콘솔의 Check로 확인하라.
  2. 최소 연체 일수 이상인 대출만 반환하는 AtLeastOverdue 확장 메서드를 작성하라. source가 Nothing이거나 최소 일수가 음수이면 호출 시점에 예외가 발생해야 한다. 최소 일수 3을 적용했을 때 반환할 제목을 적어라.
  3. 같은 overdue에 Count()와 ToArray()를 차례로 호출하면 원본이 몇 번 방문되는지 설명하라. 원본을 한 번만 읽고 개수와 제목 목록을 함께 사용하는 코드로 바꾸어라.
  4. ReservationNumbers에서 200부터 생성한 번호 중 처음 세 개를 건너뛰고 다음 네 개를 배열로 만들어라. 생성기가 내보내는 값의 개수와 최종 배열의 내용을 구분하여 설명하라.

정답과 해설

첫 연체 대출만 읽기

첫 번째 대출이 이미 연체 상태이므로 결과는 한 건이고 VisitedCount는 1이다. Take가 원본까지 정리하므로 TraversalsClosed도 1이다. 완성 코드의 기존 순회가 모두 종료된 뒤 다음 코드로 확인할 수 있다.

catalog.ResetCounters()
Dim one As Loan() = catalog.OverdueLoans().Take(1).ToArray()

Check(one.Length = 1, "한 건 반환")
Check(catalog.VisitedCount = 1, "한 항목 방문")
Check(catalog.TraversalsClosed = 1, "한 순회 정리")

Take의 인수는 결과 개수다. 원본 방문 수가 항상 같은 값이 되는 것은 아니다. 첫 항목이 연체되지 않았다면 다음 연체 항목을 찾을 때까지 원본을 더 읽는다.

최소 연체 일수 확장

다음 코드를 LibraryExtensions 모듈 안에 추가한다. 공개 함수는 인수를 검사하고, 내부 반복기는 판별과 Yield만 맡는다.

<Extension()>
Public Function AtLeastOverdue(
    source As IEnumerable(Of Loan),
    minimumDays As Integer
) As IEnumerable(Of Loan)
    If source Is Nothing Then
        Throw New ArgumentNullException(NameOf(source))
    End If
    If minimumDays < 0 Then
        Throw New ArgumentOutOfRangeException(NameOf(minimumDays))
    End If

    Return AtLeastOverdueCore(source, minimumDays)
End Function

Private Iterator Function AtLeastOverdueCore(
    source As IEnumerable(Of Loan),
    minimumDays As Integer
) As IEnumerable(Of Loan)
    For Each loan As Loan In source
        If loan.OverdueDays >= minimumDays Then
            Yield loan
        End If
    Next
End Function

catalog.AtLeastOverdue(3)의 제목은 원본 순서대로 골목의 나무, 오래된 우체통이다. 최소 일수 0을 주면 연체 0일인 대출도 포함한다. 매개변수의 의미가 최소 연체 일수라는 조건이므로 이 경계값의 동작도 호출자가 이해할 수 있게 정해야 한다.

한 번 읽은 결과 재사용하기

이 예제에서 OverdueCore가 반환한 시퀀스에 Count()를 호출하면 원본 네 건을 읽는다. 이어서 ToArray()를 호출하면 원본 네 건을 다시 읽는다. 카운터를 처음에 초기화했다면 총 방문 수는 8이고 종료된 순회 수는 2다. 다른 종류의 시퀀스에는 개수를 빠르게 구하는 경로가 있을 수 있지만, 이 반복기는 직접 끝까지 읽어야 한다.

catalog.ResetCounters()
Dim saved As Loan() = overdue.ToArray()
Dim count As Integer = saved.Length
Dim titles As String() =
    saved.Select(Function(loan As Loan) loan.Title).ToArray()

Check(count = 3, "저장한 결과 개수")
Check(titles.Length = 3, "제목 개수")
Check(catalog.VisitedCount = 4, "원본 한 번 방문")
Check(catalog.TraversalsClosed = 1, "원본 한 번 정리")

결과 배열을 한 번 만들면 이후 개수 조회와 제목 추출은 배열을 사용한다. 그만큼 결과를 보관하는 메모리가 필요하므로 재계산 비용과 보관 비용을 함께 고려한다.

건너뛴 뒤 필요한 번호 읽기

Dim selected As BigInteger() =
    ReservationNumbers(New BigInteger(200)).
        Skip(3).
        Take(4).
        ToArray()

Check(selected.Length = 4, "선택한 번호 개수")
Check(selected(0) = New BigInteger(203), "선택한 첫 번호")
Check(selected(3) = New BigInteger(206), "선택한 마지막 번호")

생성기는 200부터 206까지 일곱 값을 내보낸다. Skip은 앞의 200, 201, 202를 소비하지만 결과로 전달하지 않는다. Take는 이어지는 203, 204, 205, 206을 전달하고 순회를 끝낸다. 최종 배열의 길이는 4지만 생성기가 내보낸 값은 7개다. 결과 수와 원본 작업량을 구분하는 관점은 연체 대출 필터와 번호 생성기 모두에 적용된다.

댓글 0

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

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