글쓴이 보관물: sooopd

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

Sequence

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

(Swift) Array – 01. 생성과 조작

배열은 대부분의 프로그래밍 언어에서 가장 중요하게 다뤄지는 데이터 타입이며, 동시에 프로그래머들이 가장 많이 사용하게 되는 기본적인 자료 구조 중 하나이다. 이번 글에서는 Swift의 배열인 Array 타입에 대해 살펴보도록 하겠다.

배열자체는 어찌보면 일련의 값들을 연속적인 저장공간에 차례 차례 배치하여 저장한다는 가장 기본적인 컨셉을 갖는 간단한 자료 구조이다. 컨셉과 구조가 단순하고 또 두말할 나위없이 중요한 자료 구조이기 때문에 많은 언어에서 배열을 언어 차원에서 지원해주는 기능이기도 하다. Swift역시 배열을 기본 타입 중 하나로 제공하고 있다. 실제로 배열이 중요한 것은 그 자체가 가지는 기능보다는 보다 고차원적인 자료 구조를 구현하기 위한 베이스로 사용되기 때문이라 배열에 대해서 만큼은 잘 이해하고 있는 것이 중요하다. 뿐만아니라 Swift에서 Array는 이 언어가 지향하는 Protocol-Oriented라는 컨셉이 어떻게 언어에 녹아있는지를 보여주는 타입이기도 하다. 때문에 Array 타입에 대한 이해와 더불어 이를 지탱하는 두 프로토콜에 대해서도 살펴보겠다.

목차

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

생성

배열을 만드는 방법은 크게 배열 리터럴1Array 타입의 생성자2를 이용하는 방법이 있다. 배열 리터럴은 대괄호([ ])속에 컴마로 구분된 값을 쓰는 것이다.

let numbers = [1, 2, 3, 4, 5]

그외에 Array 타입에 정의된 생성자로는 다음과 같은 것들이 있다.

  • init() – 빈 배열을 생성한다.
  • init(_: Sequence)Sequence 타입의 값을 받아 같은 순서로 나열된 배열을 생성한다.[^3]
  • init(arrayLiteral:...)Array(1, 2, 3, 4)와 같이 각 원소값을 직접 넘겨 배열을 생성한다.
  • init(repeating:count:) – 특정 값을 지정한 개수만큼 반복하여 배열을 생성한다.

생성자는 크게 4개 밖에 없지만 Sequence 타입을 받을 수 있는 덕분에 엄청나게 다양한 값을 받을 수 있다. 나중에 알아보겠지만, Swift 의 기본 타입 중에서는 Sequene를 지원하는 주 타입 및 보조 타입이 매우 많기 때문에 아주 많은 것들을 배열로 만들 수 있다.

// 빈 배열을 만드는 표현
// 빈 배열은 타입을 추론할 수 없기 때문에 좌변에서 타입 선언을 해야 한다.
var arr1 = Array<Int>()
var arr2: Array<Int> = Array()
var arr3 = [Int]()
var arr4: [Int] = []

// 배열리터럴
let arr5 = [1, 2, 3, 4, 5]
let arr6 = Array(arrayLiteral: 1, 2, 3, 4, 5)

// 시퀀스
let arr7 = Array(1...5) // CountableClosedRange<Int> : Sequence
let chars = Array("hello world".characters)

타입

배열은 기본적으로 같은 타입의 원소들을 가져야 한다. 동적 타입 언어들과 가장 큰 차이점인데, [1, 2.4 "hello"]와 같은 배열은 기본적으로 사용하지 않는다[^3]는 것이다. 이는 역으로 배열의 타입은 원소의 타입에 의존한다는 점이다. 즉 배열은 일종의 제네릭타입이며, 원소의 타입이 배열의 타입에 영향을 준다. 따라서 같은 배열이라 하더라도 Array<Int>Array<Double>은 다른 타입이며, 배열을 인자로 받는 함수에서 둘은 호환될 수 없다.

하지만 위의 예제에서 보았듯이 배열을 선언할 때 특별한 경우를 제외하고는 타입 선언이 필요없다. Swift의 타입 시스템은 우변으로부터 원소들의 타입을 알아내어 배열의 타입을 자동으로 추론할 수 있기 때문이다. 물론 이 추론은 항상 성공한다는 보장이 없기 때문에 가끔은 좌변에서 타입을 지정해야 할 필요가 있다. 또한 타입시스템이 추론하는데 시간이 오래 걸리는 경우도 있기 때문에 좌변에서 원소타입을 명시해주는 것이 좋은 때도 있다.

가변성

배열의 가변성은 생성한 배열을 상수에 대입했으냐에 의해 결정된다. let으로 선언한 상수에 배열을 대입하면, 이 배열은 그 크기나 원소값을 변경할 수 없다. 반대로 var에 배열을 대입하면 이 배열은 가변배열이 된다. 가변 배열은 원소를 변경하거나, 배열의 크기를 변경 (다른 원소를 추가/삽입하거나 제거)하는 것이 가능하다.

struct Foo {
  var value: Int
}

let f = Foo(value: 1)
var arr = [f, Foo(value:3), Foo(value:5)] // 이 배열은 가변배열이다.

arr.append(Foo(value:7))
arr[3].value = 9 // arr.last? -> read-only로 나오기 때문에 변경 불가
arr[0].value = 4
print(f.value) // 1
pritn(arr[0].value) // 4

위 예제에서 Foo는 값 타입이며, 이는 대입시에 복사가 일어난다. 따라서 배열에 넣은 f에서 정확하게는 f의 사본이 만들어지며, arr이 변수로 선언되었기 때문에 그 속의 Foo 인스턴스들은 모두 가변값이 된다.

반대로 fvar로 선언했다 하더라도 arrlet으로 선언된 경우에는 불변버전의 사본이 배열에 들어가기 때문에 참조를 통해서 내부 속성을 변경할 수 없다.

만약 원소가 참조 타입(클래스)인 경우에는 그 내부속성을 변경한다고 하더라도 참조 자체가 변경되는 것은 아니기 때문에 f, arr이 모두 let으로 선언되었더라도 내부의 value 속성의 변경은 가능하다. (단 arr이 상수인 경우에 append 동작은 배열 자체를 변경하는 것이므로 에러를 낸다.)

한가지 염두에두어야 하는 것은 Array는 비록 값 시멘틱을 따른 타입이지만 read-only한 작업에 대해서는 참조처럼 동작한다. 이는 어떤 배열을 다른 변수나 상수에 대입했을 때 그 즉시 메모리 복사가 일어나는 것이 아닌 기존 배열을 그대로 참조하는 것이다. 이는 읽기만 하는 경우에는 기존값을 참조하는 것이 아무런 문제가 되지 않고, 성능도 좋기 때문이다. 이후에 원본이나 사본 어느 한 곳에서 변경이 발생하면 그 때 복사가 일어나서 두 변수가 참조하는 실제 데이터가 구분되게 된다.

조작

배열에 대한 가장 기본적인 조작 방법을 생각해보자.

  • 특정 인덱스를 통한 개별 원소, 혹은 부분열을 액세스하기
  • 배열내 특정 원소가 있는지 / 특정 원소의 인덱스 확인하기
  • 배열 끝에 원소를 추가하기/빼기
  • 배열 내 특정 원소에 원소 추가하기 / 제거하기
  • 정렬하기, 뒤집기[^3] 그 외 변형

개별 방법에 대해 알아보기에 앞서서, 이러한 조작은 비단 Array에 해당되는 것이 아니라는 점에 대해서 짚고 넘어가자. 예를 들어 다 같은 타입을 가지는 원소값들의 순서 없는 집합Set의 경우에도 원소추가, 제거, 특정 원소 포함 여부, 정렬하기3 등의 작업이 가능하다. 또 Dictionary 타입의 경우에도 단순한 숫자 인덱스가 아닌 문자열이나 객체 인덱스를 이용해서 개별 원소를 액세스하거나, 특정 인덱스에 해당하는 값이 포함되어 있거나 하는 등의 일이 가능하다.

다시말에 위에서 언급한 대부분의 조작들은 그것이 Array가 제공하는 특별한 동작이라기 보다는 여러 값들을 하나의 집합으로 간주하고 취급하는 Array라는 자료 구조 혹은 컨셉에서부터 유도되는 쓰임새라는 것이다. 당연히 이들은 Collection, Sequence에 정의된 동작들이며, Array 역시 이들 프로토콜을 따르는 것으로 이런 기능들을 지원한다.

일단 언급한 작업들에 대해서 사용방법들 알아보자.

서브스크립션

먼저 배열은 정수값을 통한 인덱스를 사용하며, 이 인덱스는 0부터 시작한다. anArray[0] 과 같이 대괄호 속에 인덱스를 붙여서 쓰는 전통적인 C 문법과 동일한 subscription 문법을 사용한다.

기본적으로 이 문법은 getter의 역할을 하는데, 이 구문이 대입문의 좌변인 경우에는 setter의 역할을 할 수 있으며, 당연히 배열 자체가 상수인 경우에는 에러가 나게 된다.

인덱스 확인

특정 값이 포함되어 있는지여부는 contains(_:) 를 통해서 알 수 있다. 이는 특정 값을 받아 포함여부를 Bool 타입으로 알려준다. 특히 이는 특정 값을 꼭 찝어서 쓸 수도 있고 조건을 넣을 수도 있다.

  • contains(_ : Element) -> Bool
  • contains(where: (Element) throws -> Bool ) rethrows -> Bool

따라서 다음과 같이 쓸 수 있다.

let numbers = Array(1...11)
print(numbers.contains(5)) // true
print(numbers.contains{ $0 > 11 }) // false

특정한 원소의 인덱스를 알고 싶다면 index(*:)를 사용할 수 있다. 가장 대표적인 것은 index(of:)로 이는 특정 값이 위치한 인덱스 값을 알려준다3 비슷하게 값이 아닌 조건을 사용하는 index(where:)이 있다. 이것말고 인덱스값 자체를 조작하는 index(after:), index(before:), index(_:offsetBy:limitedBy:) 가 있는데 이는 특정 인덱스를 기준으로 상대위치의 다른 인덱스를 찾는 메소드이다.4

  • index(of:) -> Int?
  • index(where: (Element) throws -> Bool ) rethrows -> Int?
  • index(_:Int, offsetBy: Int, limitedBy: Int) -> Int
  • index(_:Int offsetBy: Int) -> Int
  • index(after:Int) -> Int
  • index(before:Int) -> Int

원소 추가

특정 배열 끝에 원소를 추가하는 것은 append(_:)append(contentsOf:) 를 사용할 수 있다. 전자는 1개의 원소를 추가하며, 후자는 다른 Sequence 혹은 Collection의 원소들을 사용할 수 있다. (배열의 아니라 Seqeuence인 점에 주목하라) 즉 배열 뿐만아니라 다른 배열의 조각(ArraySlice)이나 Set 심지어는 원소 타입만 맞으면 셀 수 있는 범위값도 들어갈 수 있다.

확장전략

모든 배열은 각각 자신의 콘텐츠를 저장할 공간을 확보한다. 만약 원소나 배열을 추가하여 저장 용량에 초과가 발생하면 배열은 더 큰 용량의 공간을 할당하고 자신을 새 영역으로 복사한다. 이 때 새로 할당되는 크기는 기존 크기의 배수크기를 가진다. 이러한 지수적 공간 확장 전략은 잦은 원소 추가에 대해 재할당 횟수를 줄이는 최적화 전략이다. 확장 시 재할당은 매우 비싼 퍼포먼스 비용을 가지기 때문에 이러한 재할당이 적게 일어날 수록 유리하기 때문이다.

원소 삽입

원소 삽입은 원소 추가와 비슷한데, 배열의 끝이 아닌 특정 위치를 받아 배열 중간에 새 원소가 들어간다는 점이 다르다. index(_:, at:) , index(contentsOf:at:) 이 있고, 후자의 패턴은 위의 append(contentsOf:)와 동일하다.

원소 제거

특정 위치의 원소를 제거할 수 있다. remove(at:)을 사용한다. 이 메소드는 원본에서 특정 위치의 원소를 뺀 다음 그 원소를 리턴한다. 특이하게도 @discardableResult 디렉티브가 붙어있기 때문이 이 결과값은 그냥 무시해버려도 된다.

배열 전체를 비워버리는 removeAll(keepingCapacity:)가 있다. 이는 모든 원소를 제거해버리는데, 필요한 경우에 따라 원래의 스토리지를 유지하게 할 수 있다.5

그외에도 특정한 범위를 지정해서 원소를 제거할 수 있다. removeSubrange(_:)는 범위를 받아서 제거할 수 있으며, removeLast()append()의 반대 동작으로 맨 끝의 원소를 떼내어 리턴한다.6

치환

원소의 제거와 삽입을 같은 위치나 범위로 잡고 순서대로 실행하면 치환이 된다. 단일 원소의 치환은 anArray[3] = 1 이런 식으로 하면 되는 거고, 특정 부분열의 변경은 replaceSubrange(_:with:)를 통해서 실행한다. 이때 목표 범위와 치환하려는 값의 크기는 달라도 상관없다. (제거 후 그 위치에 삽입임)

결합

같은 타입의 배열과 배열은 서로 연결이 가능한데, + 연산자를 통해서 연결한다.

정렬 및 뒤집기

sort(_:) 는 제자리 정렬을 수행하고 sorted(_:) 는 정렬된 사본을 리턴한다. 기본적으로 오름차순 정렬을 하지만, 인자로 바이너리 함수 (두 개의 인자를 받는 함수)를 써서 정렬 기준을 변경할 수 있다.

그외 조작

split(_:)을 통해서 특정 원소 혹은 특정 조건을 만족하는 원소를 기준으로 배열을 쪼갤 수 있다. 이 때 리턴되는 결과는 Array<ArraySlice>이다.7

  • split(separator:Element, maxSplits: Int = default, omittingEmptySubsequences: Bool = default) -> [ArraySlice<Element>]
  • split(maxSplit:=default, omittingSubsequnces:=default, whereSperator: (Element) throws-> Bool) -> [ArraySlice<Element>]

이 메소드들에서는 공통적으로 maxSplits:, omittingEmptySubsequences:는 생략될 수 있다. 따라서split(separator:)split{ <condition> } 으로 쓴다고 이해하면 된다.

반대로 시퀀스의 배열인 경우에는 각각을 한데 묶을 수 있다. joined(), joined(separator:) 를 쓴다. 특히 문자열의 배열에서 문자열들을 하나의 긴 문자열로 결합할 때 유용하게 쓸 수 있겠다. 8

이상의 동작에서 insert*, remove*, replace*를 제외한 대부분의 동작은 SequenceCollection으로부터 물려받은 동작들이다. 그러면 Array와 관련된 이 프로토콜들에 대해서도 한 번 짚고 넘어가자. 그냥 이런 동작들을 배열이 할 수 있다… 정도까지 알아도 상관은 없지만, Swift의 패러다임에서 프로토콜 지향은 그만큼 중요하고, 또 특히 함수형 언어에서 연속열과 관련된 동작들은 중요한 부분이기 때문에 살펴볼 가치는 충분히 있다고 생각된다.

예제 : 스택 구현

Array의 기본 기능은 그야말로 스택에 필요한 모든 것을 갖추고 있다고 해도 과언이 아니다. 스택은 주로 다음과 같은 기능 조건을 요구한다.

  1. push(_:) 새 값을 밀어넣는다.
  2. pop() 넣은 역순으로 값을 꺼낸다.
  3. isEmpty 비어있는지 체크한다.
  4. top 최상위에 있는 값을 (꺼내지 않고) 확인한다.

따라서 우리는 Array 그 자체를 스택으로 만들 수도 있는데, 여기서는 배열을 저장소로 사용하는 스택을 만들어보도록 하겠다.

먼저 스택의 요건을 프로토콜로 정의해보자. 특히 스택원소의 타입을 결정할 수 없으므로 제네릭한 스택을 위해 associatedtype을 명시했다.

protocol StackLike {
  associatedtype Element
  var top: Element? { get }
  var isEmpty: Bool { get }
  mutating func push(_ value: Element)
  mutating func pop() -> Element
}

그렇다면 여기에 맞는 스택 타입을 하나 생성해보자.

struct Stack<T> : StackLike {
  private var data = Array<T>()
  var top: T? { return data.last }
  var isEmpty: Bool { return data.isEmpty }
  mutating func push(_ value: T) { data.append(value) }
  @discardableResult mutating func pop() -> T {
    return data.removeLast()
  }
}

Array 타입에서 이미 last, isEmpty 같은 프로퍼티를 제공하고 있고, push, pop에 대응하는 동작은 append, removeLast()이니 감싸기만 하면 된다.


  1. 결국 내부적으로는 init(arrayLiteral:)을 사용하는 것과 같다. 
  2. initializer를 말한다. 초기화해준다는 말인데, 생성한 후 초기화하는 것이니 생성자로 의역했다. 
  3. 없는 원소를 찾는 경우도 있기 때문에 Int? 타입을 리턴한다. 
  4. 배열이 아닌 다른 집합 종류에는 인덱스가 그냥 정수가 아닌 경우가 있기 때문에 필요하나, Array 타입의 인덱스는 Int 이기 때문에 별로 쓰이지 않는다. 
  5. 원소들을 제거하고 곧 다른 값을 채워넣으려는 용도에서 유용하다. (애초에 비우는 것 자체가 다시 채우려는 거 아닌가?) 
  6. append(_:), removeLast()를 이용하면 당연히 배열을 스택처럼 사용할 수 있다. 
  7. ArraySubsequence 타입은 Array가 아닌 ArraySlice이다. 이는 부분열이 별도의 스토리지를 갖는 독립된 값이 아니라 원본 배열의 스토리지를 참조하는 것이다. 
  8. 문자열(String)은 사실 시퀀스가 아니지만, 편의를 위해서 Element == String인 경우에만 따로 정의 되어 있다.