Swift 함수·클로저·컬렉션: 실행하고 시간까지 재 보기
인자 레이블과 inout, 변수를 붙잡는 클로저, copy-on-write 를 눈으로 보고 실측, map/filter/reduce 대 for 루프(-O), 실제 swiftc 에러까지 정리합니다.
시리즈: Swift 강좌 2/6 (초급). 1강을 끝냈거나 Swift 를 몇 줄 써 본 분을 위한 글입니다. 다 읽고 나면 호출하는 쪽에서 읽기 좋은 함수를 만들고, 클로저가 무엇을 붙잡는지 예상할 수 있고, 배열이 언제 진짜로 복사되는지 알게 됩니다.
코드 리뷰를 해 보면 신입이 쓴 Swift 는 금방 티가 납니다. true 가 무슨 뜻인지 주석이 있어야 읽히는 함수 호출, 아무도 예상하지 못한 값을 찍는 클로저, "혹시 몰라서" 손으로 복사본을 만들어 넘기는 배열. 이 글은 그 세 가지 습관을 고칩니다. 1강과 마찬가지로 파일 하나를 터미널에서 실행하고, 아래의 에러와 숫자는 모두 실제로 돌려서 얻은 것입니다.
환경: Apple Silicon Mac(M1 Pro), Swift 6.4.2. swift file.swift 는 기본적으로 Swift 5 언어 모드로 동작하고, 완성된 예제는 -swift-version 6(strict concurrency)으로도 타입 검사했습니다. 시간은 1강에서 본 것처럼 최적화 없는 측정은 의미가 없으므로 swiftc -O 로 쟀습니다.
함수: 인자 레이블, 기본값, inout
함수는 매개변수(입력)를 받아 값을 돌려줄 수 있는, 이름 붙은 코드 묶음입니다. Swift 함수에는 다른 언어에 드문 기능이 있습니다. 매개변수마다 인자 레이블, 즉 호출하는 쪽이 쓰는 이름을 함수 안에서 쓰는 이름과 따로 붙일 수 있습니다.
func greet(_ name: String, from city: String = "Seoul", times: Int = 1) -> String {
Array(repeating: "Hi \(name) from \(city)!", count: times).joined(separator: " ")
}
print(greet("Ana"))
print(greet("Ben", from: "Osaka"))
print(greet("Cy", times: 2))
Hi Ana from Seoul!
Hi Ben from Osaka!
Hi Cy from Seoul! Hi Cy from Seoul!
_ name은 "레이블 없음" 입니다. 호출은greet("Ana").from city는 호출하는 쪽은from:, 함수 안에서는city를 씁니다. 호출이 영어 문장처럼 읽힙니다.= "Seoul"은 기본값입니다. 호출할 때 생략할 수 있습니다.- 본문이 식 하나뿐이면
return없이 그 값을 돌려줍니다.
레이블은 함수 이름의 일부라서 컴파일러가 엄격하게 검사합니다.
ferr1.swift:2:13: error: missing argument label 'name:' in call
ferr2.swift:2:12: error: incorrect argument label in call (have '_:city:', expected '_:from:')
두 번째 메시지는 읽는 법을 익혀 둘 만합니다. 내가 쓴 레이블과 함수가 가진 레이블을 나란히 보여 줍니다.
inout: 함수가 내 변수를 바꾸게 하기
함수 안의 매개변수는 상수입니다. func bump(_ n: Int) { n += 1 } 은 left side of mutating operator isn't mutable: 'n' is a 'let' constant 로 실패합니다. 호출한 쪽의 변수를 바꿔야 한다면 매개변수에 inout 을 붙이고, 호출하는 쪽은 & 를 씁니다.
func addTax(to price: inout Double, rate: Double = 0.1) {
price *= 1 + rate
}
var price = 1000.0
addTax(to: &price)
print(price) // 1100.0
func swapValues(_ a: inout Int, _ b: inout Int) { (a, b) = (b, a) }
var x = 1, y = 2
swapValues(&x, &y)
print(x, y) // 2 1
틀리는 두 가지 경우의 실제 메시지:
ferr3.swift:3:12: error: passing value of type 'Double' to an inout parameter requires explicit '&'
ferr3.swift:5:12: error: cannot pass immutable value as inout argument: 'q' is a 'let' constant
& 는 호출하는 자리에 붙는 경고판입니다. "이 호출은 내 변수를 바꾼다." inout 은 꼭 필요할 때만 쓰고, 보통은 새 값을 돌려주는 편이 읽기 쉽습니다.
클로저: 이름 없는 함수
클로저는 변수에 담거나 다른 함수에 넘길 수 있는 코드 블록입니다. sorted(by:) 는 "첫 번째가 두 번째보다 앞에 와야 하는가" 를 알려 주는 클로저를 받습니다. 같은 호출을 긴 형태부터 짧은 형태까지 써 보면:
let scores = [72, 95, 58, 88]
let sorted1 = scores.sorted(by: { (a: Int, b: Int) -> Bool in return a > b })
let sorted2 = scores.sorted { a, b in a > b } // 후행 클로저, 타입 추론
let sorted3 = scores.sorted { $0 > $1 } // 축약 인자 이름
let sorted4 = scores.sorted(by: >) // 연산자도 함수다
print(sorted1, sorted2 == sorted3, sorted3 == sorted4)
// [95, 88, 72, 58] true true
후행 클로저(trailing closure) 문법(sorted2)은 마지막 클로저 인자를 괄호 밖으로 빼는 것입니다. SwiftUI 를 포함해 Swift 코드 어디서나 씁니다. $0 은 짧은 한 줄짜리에만 쓰고, 한 줄이 넘으면 매개변수에 이름을 붙이세요.
캡처: 클로저는 값이 아니라 변수를 붙잡는다
클로저는 바깥 코드의 변수를 쓸 수 있습니다. 이를 캡처라고 하며, 값의 복사본이 아니라 변수 자체에 대한 참조를 붙잡습니다.
func makeCounter() -> () -> Int {
var count = 0
return {
count += 1
return count
}
}
let c1 = makeCounter()
let c2 = makeCounter()
print(c1(), c1(), c1(), "|", c2()) // 1 2 3 | 1
var greeting = "Hello"
let byReference = { print("captured var:", greeting) }
let byValue = { [greeting] in print("capture list:", greeting) }
greeting = "Goodbye"
byReference() // captured var: Goodbye
byValue() // capture list: Hello
count 는 돌려준 클로저가 붙잡고 있으므로 makeCounter() 가 끝난 뒤에도 살아 있고, makeCounter() 를 부를 때마다 새 count 가 생깁니다. byReference 는 만들어진 뒤에 바뀐 greeting 을 봅니다. 캡처 리스트 [greeting] 은 클로저가 만들어지는 순간의 값을 복사합니다. "클로저가 엉뚱한 값을 찍는다" 는 버그 대부분이 이 차이로 설명되고, 6강에서 다룰 [weak self] 도 바로 이 캡처 리스트 자리에 씁니다.
반복문은 안전합니다. for i in 1...3 { handlers.append { print("handler", i) } } 는 handler 1, 2, 3 을 찍었습니다. 반복마다 i 가 따로 생기기 때문입니다.
@escaping
클로저 매개변수를 나중을 위해 저장하려 하면 컴파일러가 막습니다.
var saved: (() -> Void)?
func later(_ work: () -> Void) {
saved = work
}
esc2.swift:3:11: error: assigning non-escaping parameter 'work' to an '@escaping' closure
2 | func later(_ work: () -> Void) {
| `- note: parameter 'work' is implicitly non-escaping
_ work: @escaping () -> Void 로 씁니다. 이 키워드는 "이 클로저는 함수가 끝난 뒤에 실행될 수 있다" 는 표시이고, 시리즈 후반의 메모리·스레드 이야기에서 중요해집니다. 이렇게 고치면 기본(Swift 5) 모드에서 실행되어 later { print("ran later") }; saved?() 가 ran later 를 찍었습니다. 하지만 -swift-version 6 에서는 다른 이유로 실패합니다. main actor-isolated var 'saved' can not be mutated from a nonisolated context. Swift 6 모드에서 맨 바깥의 var 는 메인 액터 소속이기 때문입니다. 실제 코드라면 클로저를 타입의 프로퍼티에 저장하며, 3강에서 다룹니다.
Array·Dictionary·Set 은 값 타입
Swift 의 Array, Dictionary, Set 은 값 의미론(value semantics) 을 가집니다. 다른 변수에 대입하면 통째로 복사한 것처럼 동작합니다. 파이썬·자바·자바스크립트·C# 에서 두 이름이 리스트 하나를 가리키는 것과 정반대입니다.
var a = [1, 2, 3]
var b = a // 복사처럼 보인다
b.append(4)
print("a:", a, "b:", b) // a: [1, 2, 3] b: [1, 2, 3, 4]
var dict = ["apple": 3]
var dict2 = dict
dict2["apple"] = 10
print(dict, dict2) // ["apple": 3] ["apple": 10]
var tags: Set = ["swift", "ios"]
print(tags.insert("swift"), tags.count)
// (inserted: false, memberAfterInsert: "swift") 2
배열을 함수에 넘겨도 복사본이 넘어가므로, inout 이 아니면 함수가 내 배열을 바꿀 수 없습니다.
그렇다고 대입할 때마다 100만 개를 복사하면 느리겠죠. 그래서 Swift 는 copy-on-write(쓰기 시 복사) 를 씁니다. 한쪽이 쓰기 전까지는 저장 공간을 공유합니다. 두 배열의 버퍼 주소가 같은지 찍어 보면 눈으로 확인할 수 있습니다.
func address(_ arr: [Int]) -> String {
arr.withUnsafeBufferPointer { String(describing: $0.baseAddress!) }
}
var big = Array(0..<5)
var copy = big
print("same buffer before write:", address(big) == address(copy)) // true
copy[0] = 99
print("same buffer after write: ", address(big) == address(copy)) // false
진짜 복사는 얼마나 비쌀까요? 원소 1,000,000 개짜리 [Int](8 MB), 1,000 라운드, swiftc -O, 3회 실행:
| 패턴 (1,000 라운드) | 1회 | 2회 | 3회 |
|---|---|---|---|
let c = source, 읽기만 |
6.2 µs | 4.8 µs | 5.2 µs |
var c = source; c[r] = -1 |
228 ms | 233 ms | 233 ms |
| 한 번 복사한 뒤 1,000번 쓰기 | 0.19 ms | 0.22 ms | 0.23 ms |
공유는 사실상 공짜입니다. '복사 후 쓰기' 는 매번 8 MB 를 복제해 라운드당 약 0.23 ms 가 들었습니다. 이미 혼자 가진 배열에 쓰는 것은 추가 비용이 없습니다. 규칙: 대입은 마음껏 하되, 루프 안에서 복사본을 만들고 바로 수정하지 말 것. (읽기만 하는 행이 이렇게 작은 데는 옵티마이저가 쓸모없다고 증명한 일을 건너뛴 영향도 있습니다. 요점은 8 MB 복사가 일어나지 않았다는 것입니다.)
map·filter·reduce 대 for 루프
map 은 원소를 하나씩 변환하고, filter 는 조건을 통과한 것만 남기고, reduce 는 전체를 값 하나로 합칩니다. Int 1,000만 개에서 짝수의 제곱합을 세 가지 방법으로 구해 봤습니다.
// Sum of squares of even numbers, three ways. Fixed seed 42.
var seed: UInt64 = 42
var data: [Int] = []
for _ in 0..<10_000_000 {
seed = seed &* 6364136223846793005 &+ 1442695040888963407
data.append(Int(seed >> 54)) // 0...1023
}
let clock = ContinuousClock()
func loopVersion(_ xs: [Int]) -> Int {
var total = 0
for x in xs where x % 2 == 0 { total += x * x }
return total
}
func chainVersion(_ xs: [Int]) -> Int {
xs.filter { $0 % 2 == 0 }.map { $0 * $0 }.reduce(0, +)
}
func lazyVersion(_ xs: [Int]) -> Int {
xs.lazy.filter { $0 % 2 == 0 }.map { $0 * $0 }.reduce(0, +)
}
for round in 1...3 {
var r1 = 0, r2 = 0, r3 = 0
let t1 = clock.measure { r1 = loopVersion(data) }
let t2 = clock.measure { r2 = chainVersion(data) }
let t3 = clock.measure { r3 = lazyVersion(data) }
print("round \(round): for \(t1) | filter.map.reduce \(t2) | lazy \(t3) | equal: \(r1 == r2 && r2 == r3) \(r1)")
}
3라운드씩 두 번 실행했고, 모두 1741581073024 를 돌려줬습니다.
| 방법 | -O 6회 중앙값 |
-Onone (3라운드) |
|---|---|---|
for … where |
33.4 ms | 0.83 ~ 0.86 s |
.lazy.filter.map.reduce |
36.5 ms | 1.57 ~ 1.65 s |
.filter.map.reduce |
53.4 ms | 1.73 ~ 1.90 s |
일반 체인이 느린 이유는 filter 가 약 500만 개짜리 새 배열을 만들고, map 이 또 하나를 만들기 때문입니다. .lazy 는 원소 하나를 체인 끝까지 통과시킨 뒤 다음 원소로 넘어가므로, -O 에서는 루프와 10% 이내로 붙었습니다. 기본은 읽기 쉬운 쪽을 고르고, 큰 배열을 도는 핫패스라면 .lazy 나 루프를 씁니다.
딕셔너리 조회는 옵셔널을 돌려준다
딕셔너리는 키를 값에 대응시킵니다. 없는 키를 묻는 것은 흔한 일이므로 결과가 옵셔널입니다.
let stock = ["apple": 3, "pear": 0]
print(stock["apple"] as Any, stock["kiwi"] as Any) // Optional(3) nil
print(stock["kiwi", default: 0]) // 0
if let pears = stock["pear"] { print("pears in stock:", pears) } // pears in stock: 0
var counts: [Character: Int] = [:]
for ch in "banana" { counts[ch, default: 0] += 1 }
print(counts.sorted { $0.key < $1.key })
// [(key: "a", value: 3), (key: "b", value: 1), (key: "n", value: 2)]
pear 를 보세요. 키가 있고 값이 0 이므로 if let 은 성공합니다. "없음" 과 "0" 은 다르고, 그래서 조회 결과가 옵셔널인 것입니다. 바로 계산에 쓰면 실패합니다.
dict_err.swift:2:18: error: value of optional type 'Int?' must be unwrapped to a value of type 'Int'
딕셔너리를 출력할 때는 위처럼 정렬한 뒤에 하세요. Dictionary 의 순서는 정해져 있지 않고 실행할 때마다 바뀔 수 있습니다.
흔한 실수: Index out of range
딕셔너리와 달리 배열은 옵셔널을 돌려주지 않습니다. 잘못된 인덱스는 크래시입니다.
let items = ["a", "b", "c"]
for i in 0...items.count { // 0...3 은 3 을 포함한다
print(i, items[i])
}
$ swiftc oob.swift -o oobd && ./oobd
0 a
1 b
2 c
Swift/ContiguousArrayBuffer.swift:692: Fatal error: Index out of range
$ echo $?
133
1강의 강제 언래핑과 마찬가지로 -O 빌드는 0 a, 1 b, 2 c 를 찍은 뒤 메시지 없이 종료 코드 133 으로 멈췄습니다. 고치는 방법은 인덱스 계산을 직접 쓰지 않는 것입니다.
for i in items.indices { print(i, items[i]) } // 유효한 인덱스만
for (i, v) in items.enumerated() { print(i, v) } // 인덱스와 값
print(items.first ?? "-", items.last ?? "-") // 옵셔널 접근
0..<items.count 도 맞습니다. 0...items.count 가 전형적인 한 칸 오차(off-by-one)입니다.
초보자가 자주 만나는 에러 모음 (실제 메시지)
| 쓴 코드 | swiftc 메시지 | 고치는 법 |
|---|---|---|
let arr = [1, 2]; arr.append(3) |
cannot use mutating member on immutable value: 'arr' is a 'let' constant |
var arr |
let empty = [] |
empty collection literal requires an explicit type |
let empty: [Int] = [] |
let mixed = [1, "two", 3.0] |
heterogeneous collection literal could only be inferred to '[Any]'; add explicit type annotation if this is intentional |
한 타입으로 통일하거나 enum |
for n in nums { n *= 2 } |
left side of mutating operator isn't mutable: 'n' is a 'let' constant |
nums = nums.map { $0 * 2 } |
-> Int 인데 return 이 없는 함수 |
missing return in global function expected to return 'Int' |
return s 추가 |
Set<Int> 에 s[0] |
no exact matches in call to subscript |
Set 은 순서가 없다. contains 사용 |
하나 더는 경고인데, 꽤 교묘합니다. map 안의 여러 줄짜리 클로저 마지막 줄이 return 없이 u 만 있을 때:
e.swift:4:5: warning: expression of type 'String' is unused [#NoUsage]
e.swift:2:5: warning: constant 'upper' inferred to have type '[()]', which may be unexpected
값을 암묵적으로 돌려주는 것은 식 하나짜리 클로저뿐입니다. 문장이 여러 개면 return u 가 필요하고, 안 쓰면 빈 튜플의 배열이 됩니다. 경고는 에러라고 생각하고 읽으세요.
한계와 버전 메모
- Swift 6.4.2 에서만 실행했습니다.
@escaping예제를 뺀 모든 코드가 기본 Swift 5 모드와-swift-version 6양쪽에서 타입 검사를 통과했습니다. Swift 6 모드에서 벤치마크 초안이 실패했습니다. 맨 바깥var seed를 바꾸는 보조func가main actor-isolated var 'seed' can not be mutated from a nonisolated context를 냈기 때문에, 최종본은 배열을 맨 바깥 코드에서 채웁니다. - 버퍼 주소 확인에
withUnsafeBufferPointer를 쓴 것은 공유를 보여 주기 위해서일 뿐입니다. 버퍼 주소에 프로그램 로직을 기대지 마세요. ContiguousArrayBuffer.swift:692는 이 버전 표준 라이브러리의 줄 번호입니다. 버전이 바뀌면 달라집니다.- 여러 문장짜리 클로저의 결과 타입 추론은 Swift 버전에 따라 바뀌어 왔습니다. 위의
[()]경고는 6.4.2 가 낸 것입니다. - 시간은 기기 한 대, 툴체인 하나의 값입니다. copy-on-write 비용은 배열 크기에 비례하며, 0.23 ms 는 8 MB 기준입니다.
연습 문제
- 대소문자를 구분하지 않고 단어 수를 세는
func countWords(_ text: String) -> [String: Int]를split(separator: " ")와default:첨자로 작성하세요."the cat and the hat and THE bat"의 결과를 키 순서로 출력합니다. -
아래 코드가 찍을 두 줄을 예상한 뒤 실행해 보세요.
swift var total = 10 let addFive = { total += 5 } let snapshot = { [total] in print("snapshot:", total) } addFive() addFive() snapshot() print("total:", total) -
크래시 대신
nil을 돌려주는func element(at index: Int, in list: [Int]) -> Int?를 작성하고,[10, 20, 30]에 인덱스 1, 3, -1 로 시험하세요.
정답
1.
func countWords(_ text: String) -> [String: Int] {
var counts: [String: Int] = [:]
for word in text.lowercased().split(separator: " ") {
counts[String(word), default: 0] += 1
}
return counts
}
let result = countWords("the cat and the hat and THE bat")
for key in result.keys.sorted() { print(key, result[key]!) }
and 2
bat 1
cat 1
hat 1
the 3
split 은 Substring 을 돌려주므로 String(word) 로 키 타입에 맞춥니다. 여기의 ! 는 키가 딕셔너리 자신에서 나왔으므로 안전합니다.
2. snapshot: 10 과 total: 20. addFive 는 변수를 붙잡고 두 번 바꿨고, snapshot 의 캡처 리스트는 클로저를 만들 때의 10 을 복사해 뒀습니다.
3.
func element(at index: Int, in list: [Int]) -> Int? {
list.indices.contains(index) ? list[index] : nil
}
let list = [10, 20, 30]
print(element(at: 1, in: list) as Any, element(at: 3, in: list) as Any, element(at: -1, in: list) ?? 0)
// Optional(20) nil 0
이전·다음 강좌
- 이전: Swift 강좌 1/6: let·타입·문자열·옵셔널
- 다음: Swift 강좌 3/6: 구조체·클래스·프로토콜 — 값 의미론과 참조 의미론이 만나는 곳
- 이후: 에러 처리와 제네릭, async/await 와 actor, ARC·메모리·성능
- 다른 언어의 같은 주제: Dart 2강: 함수와 컬렉션, 파이썬 2강: 함수·스코프·기본 인자, 자바 스트림·람다·Optional
자주 묻는 질문
Swift 배열을 다른 변수에 대입하면 복사되나요?
논리적으로는 그렇습니다. var b = a 다음에 b 를 바꿔도 a 는 바뀌지 않습니다. 실제 메모리는 처음 쓸 때까지 공유됩니다. b[0] = 99 전에는 버퍼 주소가 같았고 후에는 달랐습니다. 100만 개짜리 배열을 '복사 후 쓰기' 1,000회 하면 -O 로 약 0.23초, 쓰지 않는 복사 1,000회는 합쳐서 약 5마이크로초였습니다.
Swift 에서 map/filter/reduce 가 for 루프보다 느린가요?
-O 기준으로 조금 느립니다. Int 1,000만 개에서 짝수의 제곱합을 구할 때 for 루프 33.4 ms, lazy 체인 36.5 ms, filter.map.reduce 53.4 ms 였습니다(6라운드 중앙값, M1 Pro). 일반 체인은 중간 배열을 두 개 만들고, .lazy 는 그걸 피합니다.
Swift 딕셔너리 조회는 왜 옵셔널을 돌려주나요?
키가 없을 수 있기 때문입니다. stock["kiwi"] 는 nil 이었고, stock["apple"] + 1 은 "value of optional type 'Int?' must be unwrapped" 로 컴파일되지 않습니다. if let, ?? 또는 stock[key, default: 0] 을 씁니다.