(Swift) Array – 03. Collection 프로토콜

Collection

목차

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

Collection은 Sequence 프로토콜을 상속하면서 한 가지 개념(기능)을 추가했다. 그것은 인덱스를 통해서 개별 원소를 액세스할 수 있는 점이다. 따라서 Sequence와 달리 여러번이고 순회할 수 있고, 순회 시 내부 자료가 소모되지 않는다.

Collection 내의 모든 원소는 집합 구조 내에서 자신의 위치를 특정지을 수 있는 인덱스를 갖는다. 그런 동시에 Sequence를 상속하기도 했다. (하지만 이것은 Collection이 되기 위해 Sequence가 되어야 한다는 뜻은 아니다. Collection이 되면 자동으로 Sequence의 동작을 모두 수행할 수 있다는 것이다.) 1

우리는 흔히 배열에서 anArray[2] 와 같은 문법으로 인덱스에 의한 개별 원소를 액세스하고 있다. 이는 전적으로 Collection의 특징에 기반한다. 인덱스에 의한 랜덤 액세스가 가능하므로 인덱스가 가리키는 위치를 앞으로 뒤로 바꿈으로써 원소의 소모/파괴 없이 여러번 순회할 수 있는 것이 보장된다. 또한 특정 요소를 포함한다/아한다 여부만 확인하는 것이 아니라 특정 요소가 포함되어 있을 때, 즉시 해당 값을 액세스할 수 있는 인덱스를 얻는 것도 가능하다.

콜렉션의 특징은 다음과 같이 정리해볼 수 있다.

  • Sequence와 마찬가지로 각 원소들을 순회할 수 있는 연속열의 일종이다.
  • 모든 원소는 고정된 고유의 위치를 가지며, 이는 인덱스를 통해서 액세스할 수 있다.
  • 두 원소간의 거리는 인덱스의 거리로 표현할 수 있고, 두 인덱스간의 범위를 통해 처음과 끝이 아닌 중간 부분의 부분열을 얻을 수 있다.
  • 개별 원소를 소모하지 않고 인덱스를 조작하여 순회하는 것이 가능하다.

결국 인덱스에 의한 랜덤엑세스가 가능하다는 것이 콜렉션의 가장 큰 특징이라 할 수 있다. 또한 이 때의 인덱스는 전후 관계가 분명해야하므로 Comparable 프로토콜을 만족해야 한다.

프로토콜 적용하기

Collection 프로토콜의 요건은 다음과 같다.

  1. startIndex, endIndex 프로퍼티를 제공해야 한다.
  2. 인덱스를 이용해 원소를 액세스하는 subscript(_:)를 구현해야한다. 최소한 읽기용 접근자는 작성해야한다.
  3. 내부에서 인덱스를 증가시키기 위해서 index(after:) 를 구현해주어야 한다.

예를 들어 배열에서는

  1. IndexInt 타입이며
  2. startIndex = 0, endIndex = self.count 가 된다.
  3. subscript(_:)Array에서 구현되었고,
  4. index(after:){ $0 + 1 }이 된다.

사실 프로토콜을 적용함에 있어서 얻을 수 있는 장점은 비소모적인 순회이며, 실제 자료를 조작하는 부분에 있어서는 다음의 기능을 얻게 된다.

  • index(of:), index(where:)으로 특정 위치를 얻음
  • Range<Index>를 이용해서 원소 1개가 아닌 부분열을 얻음
  • index(before:), index(_:offsetBy:limitedBy:) 와 같은 인덱스 조작 메소드를 얻음

시퀀스와의 차이

예를 들어 시퀀스에서 각 요소의 순회는 makeIterator() 하여 만들어진 이터레이터에 대해 next()를 호출하여 그 결과가 nil이 나올 때까지 반복하는 것이다. (그리고 이 과정에서 개별 원소가 소모되어 버릴 수 있다.)

콜렉션은 startIndex, endIndex라는 인덱스가 움직일 수 있는 양끝의 범위를 갖는다. 그러면서 다음과 같은 절차를 통해서 실질적으로 next() 를 통해서 시퀀스가 순회하는 것과 동일한 동작을 할 수 있다.

  1. startIndex에서 시작해 self[startIndex]로 첫 원소를 얻는다.
  2. index(after:)를 이용해서 인덱스를 한 칸 전진한다.
  3. self[curentIndex]를 이용해서 2, 3 번째 원소를 순회한다.
  4. 현재 인덱스가 endIndex와 같아지면 끝에 도달한 것이므로 순회를 마친다.

이전에 만들었던 Stack 타입을 기억하시는가? 해당 타입을 Sequence가 되도록 하는 확장은 잊어버리고 그냥 Collection이 되도록 만들어보자.

protocol StackLike {
  associatedtype Element
  var top: Element? { get }
  var isEmpty: Bool { get }
  // mutating 여부는 프로토콜에서 결정해야 한다.
  mutating func push(_ value: Element) -> Void 
  mutating func pop() -> Element 
}

struct Stack<T> : StackLike {
  var data = Array<T>() // must be `internal`
  // conforming StackLike
  var isEmpty: Bool { return data.isEmpty }
  var top: T? { return data.last }
  mutating func push(_ n: T) { data.append(n) }
  @discardableResult mutating func pop() -> T { 
    return data.removeLast() 
  }
}

extension Stack : Collection {
  typealias Index = Int
  var startIndex : Int { return 0 }
  var endIndex: Int { return data.count - 1 }
  func index(after n: Index) -> Index { return n + 1 }
  subscript(i: Index) -> T {
    return data[endIndex - i]
  }
}

위 코드에서 이전 버전과 달라진 점이라고는 확장을 통해서 data에 직접 액세스하기 위해서 private 스코프 한정자를 뺐다는 점이다. (fileprivate로 대체할 수 있다.)

프로토콜 적용 부분의 코드를 보면

  1. startIndex, endIndex는 외부에서 스택에 접근할 때 사용하는 인덱스 값이다. 이는 0 ~ 데이터의 크기사이가 된다.
  2. 인덱스 증가는 +1 해주는 동작이면 충분하다
  3. 단 스택은 내부 스토리지를 역순으로 조회해야 하므로 subscript 부분만 주의해주면 된다.

그리고 주목할 점은 이 코드는 Sequence의 요구조건인 makeIterator()next()를 구현하지 않았다는 점이다. 다시 다음 코드를 보자.

var stack = Stack<Int>()
(1...5).forEach{ stack.push($0) }

for x in stack { print(x) }
// 5, 4, 3, 2, 1

let doubledCopied = stack.map{ $0 * 2 }
for x in doubledCopied { print(x) }
// 10, 8, 6, 4, 2

그저 Collection만 충족했을 뿐인데 공짜로 Sequence에 정의된 메소드들을 그대로 사용할 수 있게 되었다. 여기에는 StackCollection을 충족하기 위한 조건을 준비했을 뿐이고, Collection의 구성원리에서 내부적으로 Sequence 가 필요로 하는 요건을 만족하는 것만으로 Collection은 자동으로 Sequence를 만족할 수 있게 되고 따라서 Sequence가 정의해놓은 디폴트 메소드들을 그대로 적용받게 되었다.

이것이 바로 프로토콜 지향 프로그래밍 패러다임의 강점이다. Objective-C의 프로토콜은 Java의 인터페이스 개념처럼 특정한 동작을 한다는 약속이며, 따라서 특정 메소드를 호출할 수 있다는 근거로 그것을 유사타입으로 사용하는 수준이었지만, Swift의 프로토콜은 표준 구현을 제공함으로써, 프로토콜의 논리적인 기본 요건을 만족하게 될 때 자동으로 해당 요건을 근거로 동작할 수 있는 기능들을 가져다 쓸 수 있게 된다.2

배열은 실질적으로는 Sequence 를 직접 구현했다기보다는 Collection을 구현함으로써 Sequence의 요건을 만족하고 그에 따라 유용한 메소드들을 구비한 셈이다. 물론 Array는 Collection에 정의되지 않은 몇 가지 추가적인 동작을 수행하기도 한다. 사실 이들도 파고들어보면 Collection을 상속받는 다른 프로토콜들의 도움을 받은 셈이기도 하다.


  1. 하지만 CollectionSequence의 기본 구현을 그대로 사용하는 것이 아닌 별도의 디폴트 구현을 가지고 있다. (이터레이터를 사용하지 않고 그저 인덱스만을 이용해서 구현한다.) 다만 콜렉션이 가지는 특성들이 시퀀스의 그것을 모두 포함하기 때문에, 인덱스의 범위와 인덱스 전진 연산만으로 시퀀스가 정의한 일반 메소드 명세들을 모두 구현할 수 있다는 사실 때문에 상속관계로 표현된다. 
  2. 하스켈에서 이것은 타입클래스를 통해 이미 선보인바 있는 기능이다. 

(Swift) Array – 02. Sequence 프로토콜

Sequence

시퀀스(Sequence)는 직역하면 연속열이 될 수 있으며, 문자 그대로 개개의 원소들을 순서대로 하나씩 순회할 수 있는 타입을 의미한다. (Swift 기본 타입에 대해서는 사실상 모든 집합 타입이 이에 해당한다.) 시퀀스는 사실 Swift 문법과 밀접한 관련이 있는데, 바로 for - in 구문에 사용된다는 점이다.1 Continue reading “(Swift) Array – 02. Sequence 프로토콜” »