람다와 델리게이트 심화 - Func·Action·클로저
이 장에서 배우는 것
동네 도서관의 연체료 계산은 처음에는 단순하다. 연체 일수에 하루 요금을 곱하면 된다. 그러나 회원 할인, 청소년 감면, 연체료 상한 같은 규칙이 생기면 계산식 하나로 모든 경우를 처리하기 어렵다. 대출 목록을 순회하는 절차는 그대로인데 계산 규칙만 자주 달라지는 상황이다.
앞 장에서 제네릭 형식의 매개변수와 제약 조건을 살펴보았다. 이번에는 제네릭 델리게이트(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 |
람다를 읽을 때에는 입력 형식, 반환 형식, 바깥에서 가져오는 변수, 실제 호출 위치를 순서대로 확인한다. 계산 내용만 읽으면 클로저의 변경이나 실행 시점을 놓칠 수 있다. 특히 목록에 넣거나 함수에서 반환하는 델리게이트는 선언 위치와 실행 위치가 떨어져 있으므로 이 네 가지를 함께 살펴야 한다.
연습 문제
- 일반 회원에게는 기본 요금을 적용하고 할인 회원에게는 연체료를 받지 않는 정책을 여러 줄 Function 람다로 작성하라. 완성 코드의 세 대출에 적용했을 때 금액을 순서대로 구하라.
- 문자열을 콘솔에 출력하는 대신 List(Of String)에 저장하는 Action을 작성하라. 기본 정책으로 PrintFees를 호출하면 목록에 몇 개의 문자열이 들어가며 마지막 문자열은 무엇인지 설명하라.
- 하나의 rate 변수를 캡처하는 함수를 만들고 rate를 200에서 250으로 바꾸었다. 사흘 연체의 결과를 구하라. 생성 당시 요금으로 계속 계산하려면 코드를 어떻게 바꾸어야 하는지 작성하라.
- 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를 호출할 때마다 매개변수가 별도로 생기고 반환된 람다는 해당 호출의 매개변수를 사용한다. 따라서 서로 다른 호출로 만든 정책은 각자의 설정을 유지한다.
이번 장의 계산 함수는 대출 한 건을 처리했다. 다음에는 여러 대출을 대상으로 그룹을 만들고 자료를 연결하며 금액을 집계한다. 그때에도 함수의 입력과 반환값을 읽는 방법, 바깥 변수를 사용하는 방법, 함수를 만드는 시점과 호출하는 시점을 구분하는 방법이 쿼리의 동작을 이해하는 바탕이 된다.