Devin.KR

Visual Basic · 심화

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

람다와 델리게이트 심화 - Func·Action·클로저

Function/Sub 람다, Func(Of)·Action(Of), 클로저가 잡는 변수, 전략을 함수로 넘기기

개발자KR · 원고 갱신

이 장에서 배우는 것

동네 도서관의 연체료 계산은 처음에는 단순하다. 연체 일수에 하루 요금을 곱하면 된다. 그러나 회원 할인, 청소년 감면, 연체료 상한 같은 규칙이 생기면 계산식 하나로 모든 경우를 처리하기 어렵다. 대출 목록을 순회하는 절차는 그대로인데 계산 규칙만 자주 달라지는 상황이다.

앞 장에서 제네릭 형식의 매개변수와 제약 조건을 살펴보았다. 이번에는 제네릭 델리게이트(delegate)인 Func와 Action으로 함수의 입력과 출력을 표현한다. 기본서에서 사용한 람다 식(lambda expression)을 여러 줄로 확장하고, 람다가 바깥 변수를 사용하는 클로저(closure)의 동작을 확인한다. 마지막에는 연체료 계산 규칙을 함수로 전달하여 같은 처리 절차를 여러 정책에 재사용한다.

  • 값을 반환하는 Function 람다와 작업만 수행하는 Sub 람다를 구분한다.
  • Func와 Action의 형식 인수를 읽고 적절한 델리게이트 형식을 선언한다.
  • 클로저가 생성 당시의 값이 아니라 변수를 붙잡는다는 의미를 설명한다.
  • 계산 전략과 출력 작업을 함수로 전달하여 도서관의 처리 절차를 구성한다.
  • 콘솔 검사로 정책의 결과와 변수 캡처 동작을 확인한다.

문제 상황

도서관에는 연체된 책 세 권이 있다. 한 권은 기한을 지켰고, 한 권은 할인 회원이 사흘 늦게 반납했으며, 한 권은 일반 회원이 이레 늦게 반납했다. 직원은 같은 대출 자료를 대상으로 기본 요금, 회원 할인 요금, 상한 적용 요금을 비교하려 한다. 이번 프로그램은 연체 일수가 이미 계산된 자료를 받는다. 날짜를 읽거나 오늘을 기준으로 연체 일수를 계산하는 작업은 포함하지 않는다.

정책마다 대출 목록을 순회하는 코드를 따로 작성하면 문제가 생긴다. 어떤 코드에는 출력이 추가되고 다른 코드에는 누락될 수 있다. 대출 자료를 읽는 방식이 바뀔 때에도 여러 곳을 수정해야 한다. 반대로 순회 코드 안에 정책 이름을 검사하는 조건문을 계속 추가하면 정책이 늘 때마다 같은 프로시저를 고치게 된다.

필요한 구분은 두 가지다. 대출 목록을 차례로 처리하는 절차와 대출 한 건의 요금을 계산하는 규칙을 나눈다. 처리 절차는 계산 함수를 받아 호출하고, 계산 함수는 대출 한 건을 받아 금액을 돌려준다. 출력 역시 문자열을 받아 처리하는 작업으로 전달한다. 이렇게 하면 계산 규칙을 바꾸어도 목록을 순회하는 코드는 그대로 사용할 수 있다.

여기서 함수를 전달한다는 말은 함수의 결과를 미리 계산해서 전달한다는 뜻이 아니다. 나중에 호출할 수 있는 델리게이트를 전달한다는 뜻이다. 대출 자료가 달라질 때마다 처리 절차가 그 델리게이트를 호출하고 새로운 금액을 얻는다.

Function·Sub 람다와 델리게이트 형식

람다는 이름 있는 프로시저를 별도로 선언하지 않고 함수의 내용을 표현하는 문법이다. Visual Basic에서는 Function으로 값을 반환하는 람다를, Sub로 값을 반환하지 않는 람다를 만든다. 람다가 들어갈 변수나 매개변수의 델리게이트 형식은 입력과 반환값의 계약을 정한다. Option Strict On에서는 이 계약에 맞는 형식을 명시해 두면 잘못된 전달을 컴파일 단계에서 발견하기 쉽다.

Dim fee As Func(Of Integer, Decimal) =
    Function(days As Integer) CDec(days) * 200D

Dim show As Action(Of String) =
    Sub(message As String) Console.WriteLine(message)

Dim amount As Decimal = fee(3)
show(amount.ToString("0", Globalization.CultureInfo.InvariantCulture))

fee는 정수를 받아 Decimal을 반환한다. Func의 형식 인수가 여러 개라면 마지막 하나가 반환 형식이고, 그 앞의 형식들은 입력 매개변수의 형식이다. 따라서 Func(Of Loan, Decimal)은 Loan 하나를 입력받고 Decimal 금액을 반환하는 함수를 뜻한다. 입력이 없는 함수도 Func(Of Decimal)처럼 반환 형식만 적어 표현할 수 있다.

Action에는 반환 형식이 없다. Action(Of String)의 String은 입력 형식이다. 입력이 없는 작업에는 형식 인수 없이 Action을 쓴다. Sub 람다가 내부에서 지역 변수를 만들거나 다른 함수를 호출하더라도 호출자에게 값을 돌려주지는 않는다. 결과가 필요하면 Function과 Func를 선택해야 한다.

입력과 반환값에 따라 선택하는 델리게이트 형식
형식입력반환값도서관 예
Func(Of Loan, Decimal)대출 한 건연체료요금 계산
Func(Of Integer, Decimal, Decimal)일수와 하루 요금연체료두 값을 받는 계산
Action(Of String)출력 문자열없음처리 결과 표시
Action없음없음예약된 작업 실행

한 줄 Function 람다는 Function 뒤에 계산식을 쓴다. 계산식의 값이 반환값이므로 Return이나 End Function을 붙이지 않는다. 여러 줄 Function 람다는 문장을 여러 개 넣을 수 있고, Return으로 결과를 지정하며 End Function으로 끝낸다. 조건을 검사하거나 계산 중간값을 저장할 때에는 여러 줄 형식이 읽기 쉽다.

Dim discounted As Func(Of Loan, Decimal) =
    Function(loan As Loan) As Decimal
        Dim amount As Decimal = CDec(loan.OverdueDays) * 200D
        If loan.IsDiscountMember Then
            Return amount / 2D
        End If
        Return amount
    End Function

이 람다의 두 Return은 서로 다른 실행 경로에 있다. 할인 회원이면 첫 번째 Return에서 함수가 끝난다. 일반 회원이면 조건문을 지나 두 번째 Return에 도달한다. 계산 함수에서는 가능한 경로마다 반환값이 무엇인지 분명하게 적는 편이 좋다. 여러 줄 Sub 람다도 End Sub으로 끝내지만 값을 반환하는 Return은 쓰지 않는다.

기존에 이름 있는 함수가 있다면 AddressOf로 그 함수를 델리게이트에 연결할 수도 있다. Func가 람다만 저장할 수 있는 형식은 아니다. 짧고 사용 위치가 분명한 계산은 람다로 표현하고, 여러 곳에서 쓰는 긴 계산은 이름 있는 함수로 분리할 수 있다. 선택 기준은 문법의 길이보다 계산의 의미가 잘 드러나는지에 있다.

처리 절차는 전달받은 계산 함수를 호출하고 반환된 금액으로 출력 문자열을 만든다

클로저가 붙잡는 것은 변수다

람다 안에서 매개변수와 지역 변수만 사용하는 것은 아니다. 람다가 만들어지는 위치의 바깥 변수도 사용할 수 있다. 바깥 변수를 캡처(capture)한 람다는 그 변수를 사용하는 데 필요한 환경을 함께 유지한다. 이렇게 함수와 바깥 변수의 연결이 유지되는 것이 클로저다.

Dim dailyRate As Decimal = 200D
Dim calculate As Func(Of Loan, Decimal) =
    Function(loan As Loan) CDec(loan.OverdueDays) * dailyRate

dailyRate = 300D
Dim amount As Decimal = calculate(loans(1))

loans(1)의 연체 일수가 3이면 amount는 900이다. calculate를 만들 때 dailyRate가 200이었다고 해서 200이 고정되어 저장되는 것은 아니다. 람다는 dailyRate라는 변수에 연결되어 있고, 호출할 때 그 변수에 들어 있는 300을 읽는다. 같은 변수를 캡처한 다른 람다가 있다면 두 람다는 그 변수의 변경을 함께 관찰한다.

이 동작은 필요에 따라 유용하다. 담당자가 하루 요금을 바꾼 뒤 기존 계산 함수를 다시 호출하여 새 요금을 적용할 수 있다. 하지만 처음 만든 정책을 계속 같은 요금으로 사용하려는 경우에는 기대와 다르다. 이때에는 별도 변수에 현재 값을 복사하고 그 별도 변수를 캡처해야 한다.

Dim dailyRate As Decimal = 200D
Dim savedRate As Decimal = dailyRate
Dim calculate As Func(Of Loan, Decimal) =
    Function(loan As Loan) CDec(loan.OverdueDays) * savedRate

dailyRate = 300D

이제 dailyRate의 변경은 savedRate에 영향을 주지 않는다. Decimal 값을 다른 변수에 대입했기 때문이다. 다만 이 람다도 savedRate라는 변수를 캡처한다. 나중에 savedRate를 바꾸면 계산 결과가 달라진다. 변수 이름을 savedRate로 정했다는 사실만으로 값이 고정되는 것은 아니다. 복사한 변수를 이후에 수정하지 않는 코드 구조가 필요하다.

참조 형식에서는 한 단계 더 생각해야 한다. 목록 변수를 다른 변수에 대입하면 목록 자체가 복제되지 않는다. 두 변수가 같은 목록을 가리킬 수 있으므로 목록의 항목 변경이 람다에 보일 수 있다. 변수의 재대입을 막는 것과 그 변수가 가리키는 객체의 변경을 막는 것은 서로 다른 일이다.

완성 코드의 CreateCappedPolicy는 하루 요금과 상한을 매개변수로 받아 람다를 반환한다. 함수 호출마다 별도의 매개변수가 생기므로 서로 다른 호출로 만든 정책은 각자의 요금과 상한을 사용한다. 반환된 람다가 이 매개변수들을 사용하므로 CreateCappedPolicy가 끝난 뒤에도 필요한 값은 유지된다. 예제에서는 그 매개변수를 다시 수정하는 경로를 만들지 않는다.

클로저는 필요한 변수를 계속 사용할 수 있게 하지만 관련 데이터의 수명도 길어질 수 있다. 오래 보관하는 델리게이트가 큰 목록을 사용한다면 그 목록이 계속 필요하다고 판단될 수 있다. 짧은 계산에 숫자 하나를 캡처하는 것과 서비스 전체를 담은 객체를 캡처하는 것은 보관되는 데이터의 규모가 다르다. 람다가 실제로 어떤 바깥 변수를 사용하는지 확인하는 습관이 필요하다.

같은 변수의 값을 바꾸면 기존 람다의 결과가 바뀌지만 별도 변수에 복사한 값은 유지된다

전략을 함수로 전달하기

계산 규칙을 함수로 표현하면 호출자는 사용할 규칙을 고를 수 있다. 완성 코드의 PrintFees는 대출 목록, 계산 함수, 출력 작업을 받는다. 이 프로시저는 할인 여부나 상한 금액을 알 필요가 없다. 목록에서 대출을 하나 꺼내 policy를 호출하고, 그 결과로 만든 문자열을 writeLine에 전달하면 된다.

이때 계약은 대출 한 건에서 Decimal 금액을 계산한다는 것이다. 기본 정책과 할인 정책은 내부 계산이 다르지만 같은 Func(Of Loan, Decimal) 형식을 가지므로 같은 매개변수에 전달할 수 있다. 전략을 교체하려고 대출 자료의 형식이나 순회 절차를 바꿀 필요가 없다.

출력 작업도 같은 원리로 전달한다. 예제에서는 Console.WriteLine을 호출하는 Action을 사용하지만, 문자열을 목록에 저장하는 Action으로 바꾸어 처리 결과를 수집할 수도 있다. 출력 위치를 바꾸는 결정은 호출자가 내리고, PrintFees는 문자열을 전달하는 역할만 수행한다.

함수로 전달하기에 적합한 규칙은 입력과 출력이 작고 분명한 규칙이다. 반대로 여러 설정과 복잡한 상태를 관리하는 정책이라면 하나의 긴 람다에 모두 넣는 방식이 읽기 어려워질 수 있다. 이번 예제에서는 대출 한 건을 받아 금액 하나를 반환하는 계산에 범위를 한정한다. 또한 계산 중 대출 자료나 다른 정책의 변수를 수정하지 않아 같은 입력의 결과를 확인하기 쉽게 만든다.

완성 코드

다음 내용을 Program.vb에 넣는다. Decimal 리터럴에는 D를 붙이고 연체 일수는 CDec으로 명시적으로 변환한다. 금액 출력에는 고정된 숫자 형식을 사용한다. 대출 자료, 하루 요금, 상한은 모두 코드에 정해져 있으므로 실행할 때마다 같은 결과가 나온다.

Option Strict On
Option Explicit On

Imports System
Imports System.Collections.Generic
Imports System.Globalization

Module Program
    Sub Main()
        Dim loans As New List(Of Loan) From {
            New Loan("작은 정원", 0, False),
            New Loan("별 관찰 노트", 3, True),
            New Loan("동네 지도", 7, False)
        }

        Dim standard As Func(Of Loan, Decimal) =
            Function(loan As Loan) CDec(loan.OverdueDays) * 200D

        Dim memberDiscount As Func(Of Loan, Decimal) =
            Function(loan As Loan) As Decimal
                Dim amount As Decimal = standard(loan)
                If loan.IsDiscountMember Then
                    Return amount / 2D
                End If
                Return amount
            End Function

        Dim capped As Func(Of Loan, Decimal) =
            CreateCappedPolicy(200D, 1000D)

        Dim writeLine As Action(Of String) =
            Sub(message As String) Console.WriteLine(message)

        PrintFees("기본 정책", loans, standard, writeLine)
        PrintFees("회원 할인 정책", loans, memberDiscount, writeLine)
        PrintFees("상한 정책", loans, capped, writeLine)

        Dim currentRate As Decimal = 200D
        Dim changingPolicy As Func(Of Loan, Decimal) =
            Function(loan As Loan) CDec(loan.OverdueDays) * currentRate

        Dim savedRate As Decimal = currentRate
        Dim savedPolicy As Func(Of Loan, Decimal) =
            Function(loan As Loan) CDec(loan.OverdueDays) * savedRate

        currentRate = 300D
        writeLine("[클로저 비교]")
        writeLine("변경된 변수: " & FormatMoney(changingPolicy(loans(1))) & "원")
        writeLine("복사한 변수: " & FormatMoney(savedPolicy(loans(1))) & "원")

        Dim jobs As New List(Of Action)
        For number As Integer = 1 To 3
            Dim savedNumber As Integer = number
            jobs.Add(
                Sub()
                    writeLine("작업 " & savedNumber.ToString(CultureInfo.InvariantCulture))
                End Sub)
        Next

        writeLine("[예약한 출력 작업]")
        For Each job As Action In jobs
            job()
        Next

        Dim passed As Integer = 0
        CheckEqual(0D, standard(loans(0)), "기한 준수", passed)
        CheckEqual(600D, standard(loans(1)), "기본 요금", passed)
        CheckEqual(300D, memberDiscount(loans(1)), "회원 할인", passed)
        CheckEqual(1400D, memberDiscount(loans(2)), "일반 회원", passed)
        CheckEqual(1000D, capped(loans(2)), "상한 적용", passed)
        CheckEqual(900D, changingPolicy(loans(1)), "변수 변경", passed)
        CheckEqual(600D, savedPolicy(loans(1)), "값 복사", passed)

        writeLine("검사 통과: " & passed.ToString(CultureInfo.InvariantCulture))
    End Sub

    Private Function CreateCappedPolicy(
        dailyRate As Decimal,
        maximumFee As Decimal
    ) As Func(Of Loan, Decimal)
        Return Function(loan As Loan) As Decimal
                   Dim amount As Decimal = CDec(loan.OverdueDays) * dailyRate
                   Return Math.Min(amount, maximumFee)
               End Function
    End Function

    Private Sub PrintFees(
        policyName As String,
        loans As IEnumerable(Of Loan),
        policy As Func(Of Loan, Decimal),
        writeLine As Action(Of String)
    )
        writeLine("[" & policyName & "]")
        For Each loan As Loan In loans
            Dim amount As Decimal = policy(loan)
            writeLine(loan.Title & ": " & FormatMoney(amount) & "원")
        Next
    End Sub

    Private Function FormatMoney(amount As Decimal) As String
        Return amount.ToString("0", CultureInfo.InvariantCulture)
    End Function

    Private Sub CheckEqual(
        expected As Decimal,
        actual As Decimal,
        label As String,
        ByRef passed As Integer
    )
        If expected <> actual Then
            Throw New InvalidOperationException(
                label & ": 예상 " & FormatMoney(expected) &
                ", 실제 " & FormatMoney(actual))
        End If
        passed += 1
    End Sub
End Module

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

    Public Sub New(
        title As String,
        overdueDays As Integer,
        isDiscountMember As Boolean
    )
        If overdueDays < 0 Then
            Throw New ArgumentOutOfRangeException(NameOf(overdueDays))
        End If

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

줄별 해설

Option Strict On은 암시적인 축소 변환과 늦은 바인딩 등을 제한한다. Option Explicit On은 변수를 선언해서 사용하도록 한다. Imports 세 줄은 콘솔 출력, 목록과 열거 가능한 자료, 문화권에 독립적인 숫자 표시에 필요한 이름을 가져온다. 이 설정 아래에서 모든 계산 함수는 입력과 반환 형식이 분명하다.

loans를 선언하는 부분은 대출 세 건을 목록에 넣는다. 연체 일수 0, 3, 7은 각각 요금 없음, 회원 할인, 상한 적용을 확인하기 위한 입력이다. 목록의 순서가 출력 순서가 된다. 이번 프로그램은 자료를 정렬하거나 외부에서 읽지 않는다.

standard 선언의 한 줄 Function은 연체 일수에 200을 곱한다. CDec은 정수인 OverdueDays를 Decimal로 바꾼다. 200D 역시 Decimal이므로 계산 결과의 형식이 Func의 반환 형식과 일치한다. 금액을 계산하는 단계에서는 문자열로 바꾸지 않는다.

memberDiscount는 여러 줄 Function이다. 첫 줄에서 standard를 호출하여 기본 금액을 얻는다. 이 람다는 바깥 변수 standard를 사용하므로 그 변수도 캡처한다. 예제에서는 standard를 다시 대입하지 않는다. 할인 회원이면 금액의 절반을 반환하고, 나머지 경우에는 원래 금액을 반환한다.

capped에는 CreateCappedPolicy가 반환한 델리게이트를 저장한다. 이 호출은 대출 한 건의 요금을 계산하지 않는다. 하루 요금 200과 상한 1000을 사용하는 계산 함수를 만든다. 실제 계산은 나중에 capped에 대출을 전달할 때 이루어진다. 따라서 함수 생성과 함수 호출의 시점을 구별해야 한다.

writeLine의 Sub 람다는 문자열을 받아 Console.WriteLine에 전달한다. 이어지는 PrintFees 세 번은 동일한 목록과 출력 작업에 서로 다른 계산 함수를 전달한다. 기본 요금과 회원 할인과 상한은 각각 독립된 정책이다. 회원 할인 후 상한을 적용하는 조합으로 호출한 것이 아니므로 상한 정책에서 사흘 연체 요금은 600원이다.

currentRate를 선언한 뒤 changingPolicy는 그 변수를 직접 사용한다. savedRate에는 당시의 Decimal 값을 복사하고 savedPolicy는 그 별도 변수를 사용한다. currentRate를 300으로 바꾼 후 두 함수를 같은 대출에 호출하면 각각 900과 600을 반환한다. 이 두 줄의 차이가 변수 캡처와 값 복사를 보여 준다.

jobs는 입력과 반환값이 없는 Action을 담는다. 반복 본문의 savedNumber는 각 반복에서 별도로 선언되고 현재 number의 값을 받는다. 각 Sub 람다는 자신이 만들어진 반복의 savedNumber를 사용한다. jobs.Add는 작업을 저장할 뿐 실행하지 않는다. 뒤의 job() 호출에서 비로소 출력이 발생한다.

검사 부분은 예상 금액과 실제 금액을 CheckEqual에 전달한다. 둘이 다르면 예외가 발생해 정상 완료되지 않는다. 같으면 ByRef로 받은 passed를 하나 증가시킨다. 여기서는 일곱 가지 결과를 검사하고 모두 맞으면 마지막에 7을 출력한다. 검사 함수는 실패한 계산의 이름도 메시지에 포함하여 원인을 찾게 한다.

CreateCappedPolicy의 반환 형식은 Decimal이 아니라 Func(Of Loan, Decimal)이다. 바깥 함수의 Return은 람다를 반환하고, 람다 안의 Return은 계산한 금액을 반환한다. 두 Return은 서로 다른 함수에 속한다. Math.Min은 계산 금액과 상한 중 작은 Decimal 값을 선택한다.

PrintFees는 IEnumerable(Of Loan)을 받으므로 목록을 순회할 수 있으면 처리할 수 있다. policy(loan)은 전달된 델리게이트의 호출이다. 그 다음 줄에서 제목과 금액을 결합하여 출력 작업을 호출한다. FormatMoney는 표시만 담당하며, 정수 원 단위인 이번 정책의 금액을 소수점 없이 표시한다. 소수 금액의 반올림 규칙을 정의하는 함수로 사용한 것은 아니다.

Loan 생성자는 음수 연체 일수를 거부한다. 계산 함수들은 생성자가 허용한 대출 자료를 입력으로 받는다. 제목과 연체 일수와 회원 여부는 읽기 전용 속성으로 제공하므로 예제의 계산 도중 대출 자료를 바꾸지 않는다. 이 장의 핵심은 자료 자체보다 자료를 처리하는 함수를 어떻게 표현하고 전달하는지에 있다.

실행 결과

비어 있는 작업 폴더에서 다음 명령으로 Visual Basic 콘솔 프로젝트를 만든다. 생성된 Program.vb를 완성 코드로 교체한 뒤 실행한다. .NET 10 SDK가 설치된 환경을 전제로 한다.

dotnet new console -lang VB
dotnet run

프로그램의 예상 표준 출력은 다음과 같다. 프로젝트 생성 명령이 표시하는 안내 문구는 아래 출력에 포함하지 않는다.

[기본 정책]
작은 정원: 0원
별 관찰 노트: 600원
동네 지도: 1400원
[회원 할인 정책]
작은 정원: 0원
별 관찰 노트: 300원
동네 지도: 1400원
[상한 정책]
작은 정원: 0원
별 관찰 노트: 600원
동네 지도: 1000원
[클로저 비교]
변경된 변수: 900원
복사한 변수: 600원
[예약한 출력 작업]
작업 1
작업 2
작업 3
검사 통과: 7

실무에서 자주 틀리는 것

반환값이 필요한 계산을 Action에 넣는다

다음 코드는 연체료를 반환하려는 Function을 Action에 넣으려 한다. Action은 반환값 없는 작업의 계약이므로 이 선언은 계산 함수의 목적과 맞지 않으며 컴파일되지 않는다.

Dim calculate As Action(Of Loan) =
    Function(loan As Loan) CDec(loan.OverdueDays) * 200D

금액을 호출자에게 돌려주려면 반환 형식을 가진 Func를 사용한다. 반환된 값은 별도로 저장하거나 다른 계산에 전달할 수 있다.

Dim calculate As Func(Of Loan, Decimal) =
    Function(loan As Loan) CDec(loan.OverdueDays) * 200D

Dim amount As Decimal = calculate(loans(1))

여러 줄 Function에서 결과 반환을 빠뜨린다

한 줄 람다의 계산식을 여러 줄로 옮기면서 Return을 빠뜨리기 쉽다. 아래 람다는 amount를 계산했지만 반환값을 지정하지 않았다. 반환 경로에 관한 컴파일러 경고가 발생할 수 있고, 호출자가 기대하는 계산 결과도 전달되지 않는다.

Dim calculate As Func(Of Loan, Decimal) =
    Function(loan As Loan) As Decimal
        Dim amount As Decimal = CDec(loan.OverdueDays) * 200D
    End Function

계산한 값을 Return으로 반환한다. 조건문이 있다면 각 실행 경로에서 반환이 이루어지는지도 함께 확인한다.

Dim calculate As Func(Of Loan, Decimal) =
    Function(loan As Loan) As Decimal
        Dim amount As Decimal = CDec(loan.OverdueDays) * 200D
        Return amount
    End Function

생성 당시의 요금이 고정되었다고 생각한다

아래 코드는 정책을 만든 뒤 요금을 바꾼다. 만든 정책에 200이 보관되었다고 생각하면 결과를 잘못 예상한다. policy는 rate 변수를 읽으므로 사흘 연체에 900을 반환한다.

Dim rate As Decimal = 200D
Dim policy As Func(Of Loan, Decimal) =
    Function(loan As Loan) CDec(loan.OverdueDays) * rate

rate = 300D

정책마다 생성 당시의 요금을 유지하려면 완성 코드의 생성 함수를 사용할 수 있다. 호출할 때 rate의 Decimal 값이 매개변수로 전달된다. 이후 호출자 쪽 rate를 바꾸어도 이미 생성한 정책의 매개변수는 바뀌지 않는다.

Dim rate As Decimal = 200D
Dim policy As Func(Of Loan, Decimal) =
    CreateCappedPolicy(rate, 1000D)

rate = 300D
Dim amount As Decimal = policy(loans(1))

이 경우 amount는 600이다. 이 설명은 값인 Decimal을 전달하는 예에 해당한다. 변경 가능한 객체를 전달했다면 그 객체의 내부 변경까지 차단된다고 해석해서는 안 된다.

나중에 실행할 작업이 반복 변수를 직접 사용한다

다음 코드는 For의 제어 변수 number를 람다에서 직접 사용한다. 작업은 반복이 끝난 뒤 실행되므로 각 작업이 생성 당시의 번호를 가진다고 기대하면 안 된다. 컴파일러도 반복 변수 캡처에 관한 경고를 표시할 수 있다.

Dim jobs As New List(Of Action)
For number As Integer = 1 To 3
    jobs.Add(Sub() Console.WriteLine(number))
Next

반복 본문에서 현재 값을 별도 지역 변수에 복사한다. 그 변수를 사용한 작업을 저장하면 각 작업이 해당 반복의 값을 읽는다. 선언 위치도 중요하다. savedNumber 하나를 반복문 밖에 선언하고 반복마다 다시 대입하면 같은 변수를 공유하는 문제가 남는다.

Dim jobs As New List(Of Action)
For number As Integer = 1 To 3
    Dim savedNumber As Integer = number
    jobs.Add(Sub() Console.WriteLine(savedNumber))
Next

For Each job As Action In jobs
    job()
Next

한눈에 보기

람다의 선언과 실행을 구분하는 핵심 기준
주제기억할 규칙예제의 위치
Function 람다계산 결과를 반환한다standard, memberDiscount
Sub 람다작업을 수행하고 값을 반환하지 않는다writeLine, jobs의 항목
Func의 형식 인수마지막 형식이 반환 형식이다Func(Of Loan, Decimal)
Action의 형식 인수모두 입력 형식이다Action(Of String)
변수 캡처호출할 때 연결된 변수의 값을 읽는다changingPolicy
별도 값 보관값을 복사한 변수를 이후에 수정하지 않는다savedPolicy
함수 생성나중에 호출할 델리게이트를 반환한다CreateCappedPolicy
전략 전달처리 절차가 받은 함수를 호출한다PrintFees

람다를 읽을 때에는 입력 형식, 반환 형식, 바깥에서 가져오는 변수, 실제 호출 위치를 순서대로 확인한다. 계산 내용만 읽으면 클로저의 변경이나 실행 시점을 놓칠 수 있다. 특히 목록에 넣거나 함수에서 반환하는 델리게이트는 선언 위치와 실행 위치가 떨어져 있으므로 이 네 가지를 함께 살펴야 한다.

연습 문제

  1. 일반 회원에게는 기본 요금을 적용하고 할인 회원에게는 연체료를 받지 않는 정책을 여러 줄 Function 람다로 작성하라. 완성 코드의 세 대출에 적용했을 때 금액을 순서대로 구하라.
  2. 문자열을 콘솔에 출력하는 대신 List(Of String)에 저장하는 Action을 작성하라. 기본 정책으로 PrintFees를 호출하면 목록에 몇 개의 문자열이 들어가며 마지막 문자열은 무엇인지 설명하라.
  3. 하나의 rate 변수를 캡처하는 함수를 만들고 rate를 200에서 250으로 바꾸었다. 사흘 연체의 결과를 구하라. 생성 당시 요금으로 계속 계산하려면 코드를 어떻게 바꾸어야 하는지 작성하라.
  4. CreateCappedPolicy를 각각 200과 500, 300과 1000으로 호출하여 정책 두 개를 만들라. 이레 연체의 결과를 각각 구하고 두 정책이 서로의 설정에 영향을 주지 않는 이유를 설명하라.

정답과 해설

할인 회원 면제 정책

Dim waived As Func(Of Loan, Decimal) =
    Function(loan As Loan) As Decimal
        If loan.IsDiscountMember Then
            Return 0D
        End If
        Return standard(loan)
    End Function

세 금액은 순서대로 0, 0, 1400이다. 기한을 지킨 대출은 기본 요금이 0이고, 할인 회원의 대출은 면제 조건에서 0을 반환한다. 일반 회원의 이레 연체에는 기본 요금이 적용된다. 반환값은 모두 Decimal이며 처리 절차는 기존 PrintFees를 그대로 사용할 수 있다.

출력 문자열 수집

Dim messages As New List(Of String)
Dim collect As Action(Of String) =
    Sub(message As String) messages.Add(message)

PrintFees("기본 정책", loans, standard, collect)

messages에는 네 개의 문자열이 들어간다. 정책 제목 한 개와 대출별 결과 세 개다. 마지막 문자열은 동네 지도: 1400원이다. collect는 바깥의 messages 변수를 캡처하고 호출될 때 해당 목록에 항목을 추가한다. 이 코드는 콘솔 출력 없이도 같은 처리 절차의 결과를 수집할 수 있음을 보여 준다.

변경되는 요금과 보관되는 요금

Dim rate As Decimal = 200D
Dim fixedRate As Decimal = rate
Dim policy As Func(Of Loan, Decimal) =
    Function(loan As Loan) CDec(loan.OverdueDays) * fixedRate

rate = 250D
Dim amount As Decimal = policy(loans(1))

rate를 직접 캡처했다면 결과는 750이다. 위처럼 별도 변수 fixedRate를 사용하고 그 변수를 수정하지 않으면 결과는 600이다. 람다가 값을 자동으로 복사한 것이 아니라 작성자가 별도 변수에 값을 복사했다. 변수 하나를 사용하는 모든 람다가 같은 변경을 관찰할 수 있다는 점도 함께 기억해야 한다.

서로 다른 상한 정책

Dim first As Func(Of Loan, Decimal) =
    CreateCappedPolicy(200D, 500D)
Dim second As Func(Of Loan, Decimal) =
    CreateCappedPolicy(300D, 1000D)

Dim firstAmount As Decimal = first(loans(2))
Dim secondAmount As Decimal = second(loans(2))

첫 정책은 1400과 500 중 작은 값인 500을 반환한다. 둘째 정책은 2100과 1000 중 작은 값인 1000을 반환한다. CreateCappedPolicy를 호출할 때마다 매개변수가 별도로 생기고 반환된 람다는 해당 호출의 매개변수를 사용한다. 따라서 서로 다른 호출로 만든 정책은 각자의 설정을 유지한다.

이번 장의 계산 함수는 대출 한 건을 처리했다. 다음에는 여러 대출을 대상으로 그룹을 만들고 자료를 연결하며 금액을 집계한다. 그때에도 함수의 입력과 반환값을 읽는 방법, 바깥 변수를 사용하는 방법, 함수를 만드는 시점과 호출하는 시점을 구분하는 방법이 쿼리의 동작을 이해하는 바탕이 된다.

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

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

READER FEEDBACK

질문·의견

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

댓글 0

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

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