(Swift) Array – 04. ArraySlice

목차

  1. Array – Array 타입, 생성과 조작
  2. Array – Sequence 프로토콜
  3. Array – Collection 프로토콜
  4. Array – ArraySlice
  5. Array – NSArray

ArraySlice 와 Range

배열은 단일 인덱스를 통해 하나의 원소를 액세스하는 것 외에도 범위(Range<Index>)값을 통해 하나 이상의 원소로 이루어진 부분 배열을 액세스하는 것이 가능하다.

let a = Array<Int>(1...10)
let b = a[3..<6] // [4, 5, 6]

범위는 ... 연산자와 ..< 연산자를 통해서 만들 수 있는데, 전자는 닫힌 범위, 후자는 열린 범위이다. 열린 범위는 맨 끝 값을 포함하지 않는 범위이다. 즉, 1...10 ==> 1, 2, 3 .. 10 이고 1..<10 ==> 1, 2, 3, .. 9 이다.

범위에 대해서도 좀 더 살펴봐야하겠지만, 여기서는 이 부분열에 대해 생각해보자. 이제 배열에 대해 보다 깊숙히 들여다 볼 차례이다.

ArraySlice

Swift 의 배열은 내부적으로 연속된 공간을 할당받아서 값을 저장한다. 이는 C의 그것과 유사하며, 실제 Swift의 배열은 C의 배열과 유사한 성능을 낸다. 또한 배열은 참조가 아닌 값 시멘틱을 따르는 타입이다. 하지만 실제로 배열은 너무나 자주 쓰이는 타입이고, 이곳에서 저곳으로 전달되거나 이 변수, 저 변수에 대입되기도 하는데, 이 때마다 값의 복사가 일어나는 것은 지나친 리소스 낭비이기 때문에 내부적으로 copy-on-write전략에 의해서 단순히 참조만 전달될 때에는 마치 참조 시멘틱처럼 참조만 전달되다가 참조 중 어느 한 곳에서 변경이 일어나면 그 시점에 실제 배열의 사본이 만들어지는 구조를 택한다.

배열을 Range로 액세스하는 경우에 배열의 일부인 부분배열이 만들어진다. 부분 배열은 Array 타입이 아닌 ArraySlice 타입이다. 왜냐하면 부분배열을 Array 타입으로 만들면, “자르기”가 일어나는 시점에 부분 열이 사본으로 만들어지는 것은 물론, 원본 배열의 경우에도 다른 참조가 있다면 원본에 대한 복사도 일어날 수 있기 때문이다.

하지만 이런 글에서 간단히 예로 드는 것처럼 우리가 사용하는 배열이 항상 고작 몇 개의 정수를 보관하는 용도로는 사용되지 않는다. 실제 사용에서는 제법 큰 메모리 용량을 차지하는 – 그것도 연속적인 메모리 공간을 요구하는 – 자료 구조이다. 거대한 연속 메모리 공간을 추가적으로 할당하고 복사하는 행위는 시스템 차원에서는 너무나 비용이 큰 작업이다.

따라서 배열의 부분열은 기본적으로 writing 하지 않을 것이라는 가정을 한다. 그리하어 ArraySlice는 원래 배열의 스토리지를 참조한다. 그러면서 Collection 프로토콜을 비롯하여 Array가 제공하는 API들을 제공한다. ArraySlice에서 치환등의 작업이 일어나면 원본 배열에 영향을 준다.

이런 연유로 Swift는 배열과 부분열을 구분하고 있고, 이는 용도에 근거해서 구분된 것이다. 따라서 이 둘은 엄연히 다른 타입이며, Array<T>를 요구하는 함수에 ArraySlice<T>를 넣을 수는 없다. (이 때는 Array()를 이용해서 별도의 사본 배열을 만들어야 한다.)

ArraySlice를 쓸 때의 주의점은 다음과 같다.

  1. 원본 배열 전체의 스토리지를 참조만 한다.
  2. 스토리지자체는 원본 배열이므로, 내부적으로 이중의 startIndex, endIndex를 가져서 필요한 영역만큼을 액세스하게끔 한다. 따라서 startIndex의 값은 항상 0이라는 보장이 없다. 따라서 안전을 위해서는 특정한 정수값보다는 startIndex, endIndex를 쓰도록 하자.
  3. ArraySlice가 살아있는 동안에 Array에 대한 참조는 계속 유지되므로, 이는 오랜시간동안 사용하지 않는 것이 좋다. 특히 라이프 사이클이 긴 클로저 등에서 암묵적으로 ArraySlice를 참조하지 않도록 한다. 이는 원본 배열 자체의 라이프 사이클을 강제로 연장하여 리소스 낭비의 원인이 될 수 있다.
  4. prefix, suffix 와 같은 부분열(Sequence.Subsequcene)을 리턴하는 메소드들도 모두 ArraySlice를 리턴하며, 독립적인 부분열 사본을 리턴하는게 아니다.

Range

Swift에서 범위를 나타내는 Range 타입이 있다.1 이 는 대소 비교가 가능한 두 값을 기준으로 그 내부 범위를 표현하는 타입이다. 이 때 각각의 범위는 upperBound, lowerBound이다. 2

이는 만드는 방법에 따라서 닫힌 범위와 열린 범위의 두 종류가 되는데, 열린 범위는 상위 경계값을 포함하지 않으며, 닫힌 범위는 상위 경계값도 포함한다. Swift에서 아직은 하위 경계를 포함하지 않는 종류의 범위 타입은 존재하지 않는다.

Range의 생성은 ... 이나 ..<를 쓴다.3 전자의 경우에는 upperBound값이 영역에 포함되며 이 경우에는 ClosedRange로 따로 분류한다.

let underFive = 0.0..<5.0

print(underFive.contains(3.2)) // true
print(underFive.contains(5.0)) // false
print(underFive.contains(7.1)) // false

Rangecontains(_:) 메소드를 갖고 있지만 Sequence는 아니다. 그저 특정값이 해당 범위에 들어가는지만 검사한다.

메소드

  • contains(Bound) : 특정 값이 범위내에 있는 지 검사
  • clamped(to:Range<Bound>): 다른 Range와 겹치는 부분을 구한다.4
  • overlaps(_:) 다른 Range, ClosedRange와 비교하여 겹치는 부분이 있는지를 검사한다.
  • ~= 연산자를 지원한다. Range ~= Bound 의 형식으로 써서 우변의 값이 좌변의 영역 내에 있는지를 검사한다.

if case Range = Boundif Range ~= Bound와 같다.

주목할 부분은 Bound는 비교가능하기만 하면 되는데, String도 비교 가능하다. 따라서 다음과 같은 계산도 가능하다. 5

let lowerCased = "a"..."z"
print(lowerCased.contains("h")) // true
print(lowerCased.contains("2")) // false

CountableRange

Range 타입은 단순한 범위임에 반해 CountableRange는 계단처럼 연속되는 값들의 범위이다. 예를 들어 Double의 경우 1.0..<3.0에는 무수히 많은 개수의 Double 타입 값이 들어갈 수 있다. 하지만 1..<3의 경우에는 1, 2 단 두 개의 값만 있는 셈이다. 즉 Range가 경사로라면 CountableRange는 계단이라 할 수 있다. 이렇게 하나씩 세어 올라갈 수 있는 성질을 Stridable이라 하는데 Swift에서는 Int 패밀리와 포인터타입이 이를 갖추고 있다.

따라서 일반적으로 정수를 이용해서 Range를 만들면 Range<Int>가 아닌 CountableRange<Int>가 생성된다.

let oneToNine = 1..<9
debugPrint(Mirror(reflecting:oneToNine)) // Mirror for CountableRange<Int>

CountableRange는 정해진 개수만큼 연속된 값을 가지며, 인덱스에 의해 특정 값을 지목할 수도 있다. 따라서 Collection의 필요조건을 만족하며, 시퀀스가 제공하는 여러 메소드들을 사용할 수 있다.

정수 인덱스 사용시 주의점

CountableRange<Int>를 생성했을 때의 유의점은 범위의 첫 인덱스가 항상 0이 아니라는 점이다.

print((-99..<100)[0]) // ERROR: ambiguous use of 'subscript'

CountableRange의 각 원소는 인덱스와 동일한 값이다. 따라서 -99..<100[0]번 인덱스의 값은 0이다. 하지만 보통 이 경우는 -99를 얻게 될 거라고 예측하게되는데 이 혼란을 피하기 위해서 CountableRange<Int> 에 대해 직접 서브스크립션을 하면 그냥 에러가 나게끔 되어 있다.

func bracket<T>(_ x: ClosedRange<T>, _ i: T) -> T {
  return x[i]
}

이런식으로 x 같이 타입이 고정된 함수에 포함시켜서 서브스크립션을 하면 된다. 물론 이 경우에도

bracket(30..<50, 12) // Error!!!    

이렇게 되는 부분은 알아두도록 하자.


  1. NSRange와는 다르다. NSRange시작위치; 길이의 정보를 담고서 특정한 구역 내부의 커버리지를 표현하는데 반해, Range는 그야말로 특정한 두 값 사이의 범위를 나타낸다. 따라서 NSRangelocation, length는 모두 정수이지만, Range의 양쪽 끝 값은 실수를 허용한다. 
  2. 당연한 이야기지만 Range의 각 끝단을 가리키는 값은 Comparable 을 만족해야 한다. 
  3. 각각 ClosedRange<T>, Range<T>를 생성하는 연산자이다. 
  4. clamped(to:)RangeRange하고만 연산가능하며, ClosedRangeClosedRange하고만 연산가능하다. 반면 overlaps(_:)Range, ClosedRange 상호간에 검사가능하다. 
  5. Swift 문자열은 <, > 연산자로 비교할 때, 대소문자를 구분하지 않는다. 이것이 Linux용 Swift에서만 발생하는 부분인지는 확인이 필요하다.