Swift의 Array는 Collection 프로토콜을 채택하고 있는 자료구조이다. 만약 Collection의 두 번째 원소를 얻으려고 하는 extension 코드를 작성해본다고 하자. 일단 가장 기본적인 로직으로!
extension Collection {
var second: Element? {
// Collection의 원소 갯수가 2개 이상인지 확인한다.
guard count >= 2 else {
// 두 개 보다 적으면, nil을 반환한다!
return nil
}
// 만약 두개 이상이면, second 원소를 반환한다.
return self[1]
}
}
그러나 위 코드의 경우 컴파일 에러가 발생한다. 당연하게도, Collection은 본인의 Index 연관타입이 Int가 아니라, 어떤 Comparable한 타입도 올 수 있기 때문이다. 위 코드에선 Index 타입이 Int라고 가정했기 때문에 당연히 에러가 발생한다.
그렇다면, 연관 타입인 Index를 활용한 코드는 아래와 같이 작성할 수 있다.
extension Collection {
var second: Element? {
// Collection의 원소 갯수가 2개 이상인지 확인한다.
guard count >= 2 else {
// 두 개 보다 적으면, nil을 반환한다!
return nil
}
// 만약 두개 이상이면, second 원소를 반환한다.
return self[index(after: startIndex)]
}
}
프로토콜 연관타입을 활용해, 비교적 깔끔하게 두 번째 원소를 얻을 수 있는 코드를 작성할 수 있었다. 그러나 Slice 라는 개념을 활용하면, 더 깔끔하고 가독성 높은 코드를 작성할 수 있다.
Slice에 대한 정보는 아래 공식문서에 자세히 나와있다.
https://developer.apple.com/documentation/swift/slice
Slice는 Collection의 View를 제공한다고 써있는데, 간단히 이해하자면, 원래 Collection의 특정 범위에 해당하는 영역만 보여주는 자료구조라고 이해하면 될 것 같다. 즉 만약 아래와 같이 dropFirst를 해서 Slice를 만들어보자
[1,2,3,4,5].dropFirst()
그렇다면 이 결과로 생성되는 Slice는, 첫 번째 원소인 1이 버려진 [2,3,4,5] 에 해당하는 범위만 보여주는 Slice가 되게 된다. 여기서 중요한게 있다면, Original Collection의 데이터를 전부 복사하는게 아닌, Original Collection을 단지 참조만 할 뿐이라는 것이다.
즉 위 공식 문서에 따르면, 어떤 Collection의 Slice를 생성하는 것은 O(1) 상수 타임안에 가능하다는 것이다.
여기서 중요한게 있다면, 원래 Collection을 복사하는게 아니라 참조만 한다는 점인데, 따라서 다음과 같은 상황에서 원래 Original Collection이 메모리에서 해제되지 않는 상황을 항상 고려해야 한다.
var testArr: [Int] = [1,2,3,4,5]
let slice = testArr.dropFirst()
testArr = []
print(slice.first) // 2
**[1,2,3,4,5] 는 형식적으론 메모리에서 해제된 것 처럼 보이지만... slice 지역변수가 계속해서 참조를 붙잡고 있으므로, 메모리에서 실제로 해제되진 않았다. 그러므로 slice.first가 2를 출력하게 된다.
따라서, 만약 생성한 Slice가 오랫동안 필요하거나, 원래 Collection이 필요없어지는 경우엔, 다음과 같이 Slice를 아예 새로운 Collection으로 변환하도록 하자!**
var testArr: [Int] = [1,2,3,4,5]
let slice = Array(testArr.dropFirst()) // Slice를 Array로 다시 반환하자!
testArr = []
print(slice.first) // 2
Array는 기본적으로 Value semantic이다. 따라서 내부의 값을 변경한다면! 새로운 인스턴스가 생성되게 된다. 다음과 같은 코드를 살펴보자
var testArr: [Int] = [1,2,3,4,5]
let slice = Array(testArr.dropFirst())
testArr[1] = 4
print(slice.first) // 2
Slice를 생성한 후, 원래 testArr의 두 번째 원소를 4로 바꿨다! Array는 value semantic이기 때문에, slice의 첫 번째 원소(실상은 Original Collection의 두 번째 원소)가 여전히 2를 출력한다.
extension Collection {
var second: Element? {
dropFirst().first
}
}
dropFirst는 아까도 언급했듯이, 상수타임에 가능하므로, 성능상으로도 문제가 없고 에러도 뜨지 않는, 깔끔한 코드라고 할 수 있겠다! 어쨌든 Slice는, lazy한 특성을 활용한다고 할 수 있다.
세그먼트 트리에서도 어떤 범위에 변경이 생겼을 때 이것을 연속적인 변경을 최적화하기 위해 lazy하게 변경사항을 적용하듯이, Swift에서도 나중에 Collection의 일부분의 값이 계속 필요할 수 있기 때문에 이것을 lazy하게 할 수 있는 것이다.
var someArr = [1,2,3,4,5]
let index = someArr.endIndex
_ = someArr.removeFirst()
print(someArr[index])
위 코드를 실행하면 어떤 일이 발생할까? 우선 index를 구한 다음에, Array의 상태가 변경됐다는 것을 염두해두자. 이 때 이미 구한 index는 마지막 원소의 index였는데, Array의 첫 번째 원소가 사라졌으므로, 모든 원소가 한 칸씩 앞으로 밀렸다.
따라서 기존에 구한 index는 더 이상 유효하지 않아졌다. 이 코드를 그대로 실행시키면, 어쨌든 out of range 예외로 프로그램이 종료된다.
따라서 항상 염두해 둘 것은, Collection의 상태 변경이 끝난 후에, 필요한 Index를 구하자는 것이다!
Collection의 유용한 고차함수들인 map, filter, reduce는 모두 eager function이다. eager function이란, 원하는 행동을 즉시 수행하는 것으로, 다음과 같은 코드를 보도록 해보자
let filteredArray = (1...4000).map { $0 * 2 }.filter { $0 < 10 }
위 코드를 실행하면 어떤 일이 일어날까?
물론 map, filter, reduce는 아주 유용한 함수고, 연속적으로 이었을 때 아주 간단하게 원하는 결과를 얻게 해준다. 그러나 위에서도 보았듯이, 4개의 결과를 얻는데 4000개의 숫자에 모두 연산을 하고있다.
만약 우리가 저런 연산의 결과 중 첫 번째 결과만을 원한다면, 4천개의 숫자에 모두 연산을 가하는 것은 매우 불필요한 행위다. 이런 경우를 최적화하기 위해 Lazy 한 collection을 지원한다.
let lazyFiltered = (1...4000).lazy
.map { value -> Int in
print("\(value) * 2")
return value * 2
}
.filter {
print("\($0) < 10")
return $0 < 10
}
print(lazyFiltered.first!)
// 출력 결과
1 * 2
2 < 10
1 * 2
2
lazy collection이란, 원하는 연산을 값이 실제로 필요할 때 까지 미루는 것을 의미한다. 즉 위의 lazyFiltered의 값을 실제로 호출하기 전까진, map 과 filter연산이 이루어지지 않는다.
lazy collection으로 이루어진 모든 연산은 실제로 lazy collection의 값이 필요하기 전에는 수행되지 않는다.
lazy collection의 결과 중 일부분만 필요한 경우(first라던가...), 요구사항을 만족해서 첫 번째 원소가 나온다면, 이후 연산은 모두 중지한다. 출력 결과를 보면, 4000개의 모든 숫자에 대한 연산을 수행하지 않고, 첫 번째 결과인 2가 나오자마자 바로 모든 연산이 중지된 것을 볼 수 있다.
lazy collection의 경우 연산 순서도 특이하다. 일반 map과 filter의 경우, 연속적으로 이어진 다음 고차함수를 실행하기 이전에, 모든 원소에 대해 연산을 수행해야 한다.
그러나 lazy collection의 경우 하나의 원소에 대해 연속적으로 이어진 연산을 순서대로 실행한다. 따라서 위의 코드도 한 개의 원소에 대해 map과 filter 연산을 연속적으로 수행했다.
즉 lazy collection은 collection의 원소 중 일부분만 필요하고, 모든 원소에 대한 연산을 적용하지 않을 때는 유용하다.
그러나 Collection의 원소 개수가 많을 때만 유의미한 성능 차이를 보이고, 원소 개수가 5개인데 lazy를 쓰는건 별로 의미가 없다.
또한 lazy collection의 결과는 cache 되지 않는다.
다른 말로 얘기하자면, 연산의 결과가 저장되지 않기 때문에 lazy collection의 어떤 원소가 다시 필요한 경우 다시 해당 원소를 얻기 위한 연산이 수행된다.
또한 사실 lazy가 무조건 장점이 될 수는 없는게, 유저가 어떤 액션을 취하기 전에 미리 (eager하게) 연산을 모두 수행한다면, 유저가 이후에 scroll 하는 도중 버벅임을 느낄 필요가 없다.(물론 이런 큰 계산은 백그라운드 스레드에서 처리하는게 낫다)
첫 번째 원소만 필요한 경우는 사실... 기존에 존재하는 where API가 더 적절할 것이다.
결론은 lazy collection이 모든 원소에 대한 연산이 필요없으므로 어떤 상황에서는 아주 유용한 API라고 할 수 있지만, 상황을 판단하며 쓸 줄 알아야 한다는 것이다. 무조건 lazy하다고 좋진 않다는 것
멀티 스레드에서 접근할 수 있는 자원의 경우, 당연하겠지만 race condition을 조심해야 한다.
Swift Collection 역시 single thread에서 동작하는데 최적화되있으므로, 멀티쓰레드가 하나의 Collection에 접근할 때는 항상 mutual exclusion을 보장해줘야 한다.
let concurrentQueue = DispatchQueue.global()
var testArr: [Int] = []
concurrentQueue.async {
testArr.append(1)
}
concurrentQueue.async {
testArr.append(2)
}
concurrentQueue.asyncAfter(deadline: .now() + 1) {
print(testArr)
}
위 코드에서, 두 개의 Thread가 testArr에 동시에 접근해서 원소를 추가한다. 실행할 때마다 당연히 결과가 뒤죽박죽으로 나오는 것을 볼 수 있을 것이다.
이를테면, 기댓값은 [1,2] 일테지만, 실제로 코드를 돌려보면 [2], [1], [1,2], [2,1] 등 아주 다양한 결과가 나온다. 심각한 경우에는 크래쉬로 이어지기도 한다.
위의 경우엔 concurrentQueue를 사용해 하나의 Collection에 접근했으니 당연히 상호배제가 보장되지 않았다.
let serialQueue = DispatchQueue(label: "serialQueue")
var testArr: [Int] = []
serialQueue.async {
testArr.append(1)
}
serialQueue.async {
testArr.append(2)
}
serialQueue.async {
print(testArr)
}
아니면, semaphore, Actor 등 다양한 API를 사용하여 mutual exclusion을 보장해주자!
https://www.avanderlee.com/swift/lazy-collections-arrays/
https://developer.apple.com/documentation/swift/slice
WWDC - Using Collections Effectively