함수와 클로저 - 인자 라벨이 있는 함수
이 장에서 배우는 것
앞 장에서는 값이 없을 수 있다는 사실을 옵셔널(Optional)이라는 타입으로 표현했다. 이 장에서는 그런 값을 받아 일을 처리하는 단위, 곧 함수와 클로저를 다룬다. 습관 기록 앱의 로직은 결국 "기록을 받아 계산하고 결과를 돌려주는" 작은 동작을 모아 놓은 것이다. 이 동작을 읽기 좋게 이름 붙이고, 호출하는 쪽에서 의미가 드러나게 하고, 배열을 한 번에 가공하는 도구를 익히면 이후 장의 코드가 훨씬 짧아진다.
- 인자 라벨과
_를 써서 호출하는 줄만 읽어도 뜻이 통하는 함수를 만든다. - 기본값과
inout, 여러 값 반환(튜플)을 상황에 맞게 고른다. - 클로저의 전체 문법과 축약 규칙, 후행 클로저를 읽고 쓴다.
map,filter,reduce,sorted(by:)로 습관 기록 배열을 가공한다.
문제 상황
물 마시기 기록이 하루에 마신 잔 수로 쌓여 있다고 하자. 일주일 치 기록이 [6, 8, 3, 8, 9, 0, 7]이다. 여기서 알고 싶은 것은 여러 가지다. 합계는 얼마인지, 목표인 8잔을 채운 날은 며칠인지, 목표를 채운 날이 가장 길게 이어진 구간은 얼마인지, 많이 마신 순서로 늘어놓으면 어떻게 되는지 같은 질문이다.
이런 코드를 for 반복문으로 하나씩 쓰면 질문마다 비슷한 반복문이 생기고, 변수가 늘어나며, 반복문 안에서 무엇을 하는지 읽어야 의도를 알 수 있다. 함수를 만들 때도 문제가 있다. record(8, 30, 1)처럼 숫자만 나열한 호출은 어느 숫자가 날짜이고 어느 숫자가 양인지 코드만 봐서는 알 수 없다. 이 장의 도구는 이 두 문제, 곧 "호출이 읽히지 않는다"와 "비슷한 반복문이 쌓인다"를 푼다.
인자 라벨이 있는 함수
함수(function)는 이름을 붙인 코드 묶음이다. 괄호 안에 받는 값을 적는데, 선언하는 쪽에서 적는 것을 매개변수(parameter), 호출하는 쪽에서 넘기는 실제 값을 인자(argument)라 부른다. Swift는 매개변수마다 이름을 두 개까지 줄 수 있다. 호출할 때 쓰는 바깥 이름(인자 라벨)과 함수 몸체 안에서 쓰는 안쪽 이름이다.
func logLine(habit name: String, day: Int, amount: Int = 1) -> String {
return "\(day)일차 \(name): \(amount)"
}
첫 매개변수는 바깥 이름이 habit, 안쪽 이름이 name이다. 호출하면 logLine(habit: "운동", day: 2)처럼 읽힌다. 이름을 하나만 쓴 day는 바깥 이름과 안쪽 이름이 같다. 몸체에서는 name과 day를 쓰고, 호출에서는 habit:과 day:를 쓴다.
밑줄로 라벨 생략하기
호출만 봐도 뜻이 분명한 첫 번째 인자는 라벨이 오히려 군더더기다. 이때 바깥 이름 자리에 밑줄 _를 쓰면 라벨 없이 값만 넘긴다.
func isDone(_ amount: Int, goal: Int) -> Bool {
return amount >= goal
}
isDone(8, goal: 8)
"8이 목표 8을 채웠는가"처럼 읽히므로 첫 인자에는 라벨이 필요 없고, 둘째 인자는 무엇인지 밝히려고 라벨을 남겼다. 라벨을 지을 때의 기준은 호출한 줄을 소리 내어 읽었을 때 자연스러운 문장이 되는지다.
기본값
매개변수에 = 1처럼 기본값을 적으면 호출할 때 그 인자를 빼도 된다. 위의 amount가 그렇다. logLine(habit: "물 마시기", day: 1)은 양을 1로 보고, logLine(habit: "운동", day: 2, amount: 30)은 30을 쓴다. 기본값이 있는 매개변수는 보통 뒤쪽에 둔다. 값을 넘기는 쪽이 필수 인자를 먼저 쓰고 선택 인자를 나중에 덧붙이는 순서가 자연스럽기 때문이다.
inout: 호출한 변수를 직접 고치기
함수의 매개변수는 기본적으로 상수다. 몸체 안에서 값을 바꾸려 하면 컴파일 오류가 난다. 호출한 쪽의 변수를 함수가 바꿔야 한다면 타입 앞에 inout을 붙인다. 호출할 때는 변수 앞에 &를 붙여 "이 변수가 바뀐다"는 사실을 눈에 보이게 한다.
func addRecord(_ amount: Int, to total: inout Int) {
total += amount
}
var total = 0
addRecord(8, to: &total)
넘기는 것은 var로 선언한 변수여야 하고, let 상수나 숫자 리터럴은 넘길 수 없다. inout은 호출이 끝나는 시점에 바뀐 값이 원래 변수로 돌아간다고 이해하면 충분하다. 다만 값을 돌려주는 방식이 더 읽기 쉬운 경우가 많으므로, 꼭 필요할 때만 쓴다.
여러 값 반환하기
결과가 여러 개일 때는 튜플(tuple)로 묶어 돌려준다. 튜플은 서로 다른 타입의 값을 괄호로 묶은 것이고, 각 자리에 이름을 붙일 수 있다.
func summarize(_ amounts: [Int]) -> (total: Int, best: Int, average: Double)? {
guard let best = amounts.max() else { return nil }
let total = amounts.reduce(0, +)
return (total, best, Double(total) / Double(amounts.count))
}
반환 타입 끝의 ?는 앞 장에서 배운 옵셔널이다. 기록이 하나도 없으면 최댓값도 평균도 없으므로 nil을 돌려준다. 호출하는 쪽은 if let s = summarize(log)로 풀어서 s.total, s.best처럼 이름으로 꺼낸다.
클로저와 고차 함수
클로저(closure)는 이름 없이 값처럼 들고 다니는 코드 묶음이다. 함수에 다른 함수를 인자로 넘기고 싶을 때 쓴다. "배열의 각 원소에 무엇을 할지"를 호출하는 쪽이 정해서 넘기면, 반복문의 뼈대는 표준 라이브러리가 맡는다. 이처럼 함수를 인자로 받는 함수를 고차 함수라 한다.
클로저 문법과 축약
가장 긴 형태는 중괄호 안에 매개변수와 반환 타입을 적고 in 뒤에 몸체를 쓴다. 문맥에서 타입이 정해지면 하나씩 줄일 수 있다.
| 단계 | 표기 | 줄인 내용 |
|---|---|---|
| 전체 | { (glasses: Int) -> Int in return glasses * 200 } | 없음 |
| 타입 생략 | { glasses in return glasses * 200 } | 매개변수·반환 타입 |
| return 생략 | { glasses in glasses * 200 } | 한 식이면 return |
| $0 사용 | { $0 * 200 } | 매개변수 이름과 in |
$0은 첫 번째 인자를, $1은 두 번째 인자를 뜻하는 자동 이름이다. 줄이는 정도는 읽는 사람 기준으로 정한다. 한 줄짜리 간단한 변환에는 $0이 깔끔하고, 몸체가 길어지면 이름을 붙이는 편이 낫다.
후행 클로저
함수의 마지막 인자가 클로저이면 소괄호 뒤에 중괄호를 붙여 쓸 수 있다. 이를 후행 클로저(trailing closure)라 한다. 직접 만든 함수에도 같은 방식이 통한다.
func countDays(in log: [Int], where condition: (Int) -> Bool) -> Int {
var count = 0
for amount in log where condition(amount) { count += 1 }
return count
}
let metCount = countDays(in: waterLog) { $0 >= goal }
후행 클로저로 쓰면 마지막 클로저 인자의 라벨(where:)은 적지 않는다. 호출이 "waterLog 안에서, 0번째 인자가 goal 이상인 날을 센다"처럼 읽힌다.
함수가 만든 클로저는 값을 붙들고 있다
클로저는 만들어진 자리의 상수와 변수를 붙들고 있다. 이를 값을 붙잡는다(capture)고 한다. 함수가 클로저를 돌려주면 그 함수가 끝난 뒤에도 클로저가 인자를 기억한다.
func makeChecker(goal: Int) -> (Int) -> Bool {
return { amount in amount >= goal }
}
let waterChecker = makeChecker(goal: 8)
let readingChecker = makeChecker(goal: 20)
두 검사기는 같은 코드에서 나왔지만 각자 다른 goal을 기억한다. 습관마다 목표가 다를 때 쓰기 좋다. 반환 타입 (Int) -> Bool은 "Int를 받아 Bool을 돌려주는 함수"라는 타입이다.
map, filter, reduce, sorted(by:)
배열을 가공하는 네 가지 도구는 각각 하는 일이 다르다.
| 함수 | 클로저가 하는 일 | 결과 |
|---|---|---|
map | 원소 하나를 다른 값으로 변환 | 같은 길이의 새 배열 |
filter | 남길지 말지 참·거짓 판단 | 같거나 짧은 배열 |
reduce | 지금까지의 결과와 원소를 합침 | 값 하나 |
sorted(by:) | 앞 원소가 먼저 와야 하는지 판단 | 정렬된 새 배열 |
reduce는 처음 값을 하나 받고, 원소를 하나씩 만날 때마다 클로저로 결과를 갱신한다. 합계는 reduce(0) { $0 + $1 }이고, +는 두 값을 받는 함수이므로 reduce(0, +)로도 쓴다. 결과를 갱신하며 가는 상태가 여러 개일 때는 reduce(into:)가 편하다. 완성 코드에서는 현재 연속 달성일과 최장 기록을 튜플 하나에 담아 연속 달성일을 구한다.
sorted(by:)는 원본을 바꾸지 않고 정렬된 새 배열을 돌려준다. 딕셔너리를 정렬하면 각 원소가 key와 value를 가진 튜플로 나오므로 $0.value > $1.value처럼 값으로 비교할 수 있다. 딕셔너리는 순회 순서가 정해져 있지 않으므로, 출력할 때는 정렬을 거치는 습관이 필요하다.
자세한 사양은 Swift 공식 문서의 함수 항목과 클로저 항목에서 확인할 수 있다.
완성 코드
아래를 main.swift 한 파일로 저장한다.
import Foundation
func logLine(habit name: String, day: Int, amount: Int = 1) -> String {
return "\(day)일차 \(name): \(amount)"
}
func isDone(_ amount: Int, goal: Int) -> Bool {
return amount >= goal
}
func addRecord(_ amount: Int, to total: inout Int) {
total += amount
}
func summarize(_ amounts: [Int]) -> (total: Int, best: Int, average: Double)? {
guard let best = amounts.max() else { return nil }
let total = amounts.reduce(0, +)
return (total, best, Double(total) / Double(amounts.count))
}
func countDays(in log: [Int], where condition: (Int) -> Bool) -> Int {
var count = 0
for amount in log where condition(amount) { count += 1 }
return count
}
func makeChecker(goal: Int) -> (Int) -> Bool {
return { amount in amount >= goal }
}
let waterLog = [6, 8, 3, 8, 9, 0, 7]
let goal = 8
// 라벨, 기본값, 밑줄
print(logLine(habit: "물 마시기", day: 1))
print(logLine(habit: "운동", day: 2, amount: 30))
print("둘째 날 달성: \(isDone(waterLog[1], goal: goal))")
// inout
var total = 0
for amount in waterLog {
addRecord(amount, to: &total)
}
print("물 총량: \(total)")
// 여러 값 반환
if let s = summarize(waterLog) {
print("합계 \(s.total), 최대 \(s.best), 평균 \(String(format: "%.1f", s.average))")
} else {
print("기록 없음")
}
print("빈 기록이면 nil: \(summarize([]) == nil)")
// 클로저 축약
let mlFull = waterLog.map({ (glasses: Int) -> Int in
return glasses * 200
})
let mlShort = waterLog.map { $0 * 200 }
print("밀리리터: \(mlShort)")
print("두 표기 결과 같음: \(mlFull == mlShort)")
// filter, map, reduce
let achieved = waterLog.filter { $0 >= goal }
print("달성한 날의 기록: \(achieved)")
let marks = waterLog.map { $0 >= goal ? "O" : "X" }
print("일별 표시: \(marks.joined())")
let sumByReduce = waterLog.reduce(0) { $0 + $1 }
print("reduce 합계: \(sumByReduce)")
let streak = waterLog.reduce(into: (current: 0, best: 0)) { state, amount in
if amount >= goal {
state.current += 1
state.best = max(state.best, state.current)
} else {
state.current = 0
}
}
print("최장 연속 달성일: \(streak.best)")
// sorted(by:)
let ranking = waterLog.sorted { $0 > $1 }
print("많이 마신 순: \(ranking)")
let weekly: [String: Int] = ["물 마시기": 41, "운동": 150, "독서": 95]
let ordered = weekly.sorted { $0.value > $1.value }
for (index, entry) in ordered.enumerated() {
print("\(index + 1)위 \(entry.key) \(entry.value)")
}
// 후행 클로저와 클로저 반환
let metCount = countDays(in: waterLog) { $0 >= goal }
print("달성 일수: \(metCount)")
let waterChecker = makeChecker(goal: 8)
let readingChecker = makeChecker(goal: 20)
print("물 9잔: \(waterChecker(9))")
print("독서 15쪽: \(readingChecker(15))")
줄별 해설
import Foundation은String(format:)을 쓰기 위해 필요하다. 소수 첫째 자리까지만 출력하려는 용도다.logLine은 라벨이 있는 함수다. 바깥 이름habit과 안쪽 이름name이 다르고,amount는 기본값 1을 가진다.isDone은 첫 인자의 라벨을_로 없앴다. 호출isDone(waterLog[1], goal: goal)에서 앞의goal:은 라벨이고 뒤의goal은 아래에서 선언한 상수다. 이름이 같지만 역할이 다르다.addRecord는total을inout으로 받아 값을 더한다. 호출 쪽에서는&total로 넘기므로 반복문이 도는 동안 바깥의total이 쌓인다.summarize는guard let으로 빈 배열을 먼저 걸러nil을 돌려준다. 이후에는best가 값이 있음을 보장받는다. 반환할 때 이름 없는 튜플(total, best, ...)이 이름 있는 반환 타입으로 그대로 들어간다.countDays는where condition매개변수로 클로저를 받는다.for ... where는 조건이 참인 원소만 반복한다.makeChecker는goal을 붙든 클로저를 돌려준다.- 첫
print두 줄은 기본값이 쓰였을 때(1)와 직접 값을 넘겼을 때(30)를 보여 준다. mlFull과mlShort는 같은 변환을 전체 문법과$0축약으로 쓴 것이다. 배열끼리==로 비교하면 원소가 모두 같을 때 참이다.achieved는 8 이상인 값만 남긴다.marks는 각 값을 "O" 또는 "X"로 바꾸고joined()로 문자열 하나로 이어 붙인다.streak의reduce(into:)에서state는 고칠 수 있는 누적 상태다. 목표를 채우면current를 올리고best와 비교해 큰 쪽을 남기며, 못 채우면current를 0으로 되돌린다.ranking은$0 > $1이 참이면 앞 원소가 먼저 오므로 내림차순이 된다.ordered는 딕셔너리를 값 기준 내림차순으로 정렬한 배열이다.enumerated()는 0부터 시작하는 순번을 함께 주므로index + 1로 순위를 만든다.countDays(in: waterLog) { $0 >= goal }은 후행 클로저 호출이다.
실행 결과
$ swiftc main.swift -o app
$ ./app
1일차 물 마시기: 1
2일차 운동: 30
둘째 날 달성: true
물 총량: 41
합계 41, 최대 9, 평균 5.9
빈 기록이면 nil: true
밀리리터: [1200, 1600, 600, 1600, 1800, 0, 1400]
두 표기 결과 같음: true
달성한 날의 기록: [8, 8, 9]
일별 표시: XOXOOXX
reduce 합계: 41
최장 연속 달성일: 2
많이 마신 순: [9, 8, 8, 7, 6, 3, 0]
1위 운동 150
2위 독서 95
3위 물 마시기 41
달성 일수: 3
물 9잔: true
독서 15쪽: false
실무에서 자주 틀리는 것
라벨을 빼고 호출한다
라벨이 있는 매개변수에 값만 넘기면 컴파일 오류가 난다.
// 틀린 코드
logLine("운동", 2)
// 고친 코드
logLine(habit: "운동", day: 2)
선언에서 _를 붙인 매개변수만 라벨을 생략할 수 있다. 라벨이 귀찮다고 모든 매개변수에 _를 붙이면 앞에서 본 record(8, 30, 1) 문제로 되돌아간다.
매개변수를 함수 안에서 고치려 한다
매개변수는 상수여서 몸체 안에서 대입할 수 없다.
// 틀린 코드
func addRecord(_ amount: Int, to total: Int) {
total += amount // 오류: total 은 상수
}
// 고친 코드
func addRecord(_ amount: Int, to total: inout Int) {
total += amount
}
var total = 0
addRecord(8, to: &total)
호출할 때 &를 빠뜨려도 오류가 난다. 그리고 let으로 선언한 상수는 &를 붙여도 넘길 수 없다.
filter 자리에 map을 쓴다
map은 거르지 않고 변환한다. 조건식을 넣으면 걸러지는 대신 참·거짓 배열이 나온다.
// 틀린 코드
let achieved = waterLog.map { $0 >= 8 } // [false, true, false, true, true, false, false]
// 고친 코드
let achieved = waterLog.filter { $0 >= 8 } // [8, 8, 9]
결과 배열의 타입이 [Bool]인지 [Int]인지 확인하면 바로 알 수 있다. "남길지 말지"를 정하려면 filter, "다른 값으로 바꾸려면" map이다.
이름 있는 인자와 $0을 섞는다
클로저에 매개변수 이름을 적었다면 같은 클로저 안에서 $0을 쓸 수 없다.
// 틀린 코드
let marks = waterLog.map { amount in $0 >= 8 ? "O" : "X" }
// 고친 코드
let marks = waterLog.map { amount in amount >= 8 ? "O" : "X" }
// 또는
let marks2 = waterLog.map { $0 >= 8 ? "O" : "X" }
둘 중 하나로 통일한다. 이름을 붙이는 쪽이 몸체가 길어졌을 때 읽기 쉽다.
한눈에 보기
| 문법 | 예 | 쓰는 때 |
|---|---|---|
| 인자 라벨 | logLine(habit: "운동", day: 2) | 호출이 문장처럼 읽히게 할 때 |
밑줄 _ | isDone(8, goal: 8) | 첫 인자의 뜻이 분명할 때 |
| 기본값 | amount: Int = 1 | 대부분 같은 값을 쓸 때 |
inout | to total: inout Int, &total | 호출한 변수를 직접 바꿀 때 |
| 튜플 반환 | (total: Int, best: Int) | 결과가 여러 개일 때 |
| 후행 클로저 | countDays(in: log) { ... } | 마지막 인자가 클로저일 때 |
| 하고 싶은 일 | 함수 | 예 |
|---|---|---|
| 모든 원소를 변환 | map | $0 * 200 |
| 조건에 맞는 것만 남김 | filter | $0 >= goal |
| 하나의 값으로 합침 | reduce | reduce(0, +) |
| 순서를 정해 나열 | sorted(by:) | $0 > $1 |
연습 문제
- 책의 남은 쪽수를 계산하는 함수
pagesLeft를 만든다. 책 이름은 라벨of, 전체 쪽수는total, 읽은 쪽수는read로 하고read의 기본값은 0이다. 전체 300쪽 중 120쪽을 읽은 경우와 읽은 쪽수를 생략한 경우를 모두 호출한다. - 연속 달성일이 최소 기준보다 작으면 0으로 되돌리는 함수
resetIfBelow(_:minimum:)를inout으로 만든다. 값이 2이고 기준이 3일 때의 결과를 확인한다. - 운동 시간 배열
[30, 0, 45, 20]에서 0분인 날을 빼고 합계를 구한다.filter와reduce를 쓴다. - 딕셔너리
["물 마시기": 41, "운동": 150, "독서": 95]를 습관 이름의 오름차순으로 정렬해 한 줄씩 출력한다.
정답과 해설
-
func pagesLeft(of book: String, total: Int, read: Int = 0) -> Int { return total - read } print(pagesLeft(of: "예제 책", total: 300, read: 120)) // 180 print(pagesLeft(of: "예제 책", total: 300)) // 300바깥 이름
of와 안쪽 이름book을 분리해 호출이 "of 책"으로 읽히게 했다. 이 풀이에서는book을 몸체에서 쓰지 않지만 이름을 붙이는 데 문제는 없다.read를 생략하면 기본값 0이 쓰여 300이 나온다. -
func resetIfBelow(_ value: inout Int, minimum: Int) { if value < minimum { value = 0 } } var streakDays = 2 resetIfBelow(&streakDays, minimum: 3) print(streakDays) // 0호출한 변수
streakDays가 직접 바뀌므로var여야 하고&를 붙여야 한다. 값이 3 이상이면 그대로 둔다. -
let minutes = [30, 0, 45, 20] let sum = minutes.filter { $0 != 0 }.reduce(0, +) print(sum) // 95filter가[30, 45, 20]을 만들고reduce가 이를 더해 95가 된다. 0은 합계에 영향을 주지 않으므로 이 문제에서는 걸러도 결과가 같다. 이어 붙이는 연습이 목적이다. -
let weekly: [String: Int] = ["물 마시기": 41, "운동": 150, "독서": 95] for entry in weekly.sorted(by: { $0.key < $1.key }) { print("\(entry.key) \(entry.value)") } // 독서 95 // 물 마시기 41 // 운동 150딕셔너리의 원소는
key와value를 가지므로$0.key < $1.key로 이름을 비교한다.for의in뒤에서는 클로저를 소괄호 안에 넣어 호출했다.