Embrace Swift type inference

WWDC 2020

스위프트는 코드의 안전성을 훼손하지 않고 간결한 코드를 작성하기 위해 타입 추론(Type inference)을 광범위하게 사용한다.

이 영상을 통해 우리는 다음의 것들을 살펴볼 것이다.

  1. 타입 추론을 활용하는 법
  2. 컴파일러에서 타입 추론이 어떻게 동작하는지
  3. 타입 추론에 의해 발생할 수 있는 에러의 원인과 이를 해결하는 방법

What is type inference?

먼저 타입 추론이 무엇인지 간단하게 알아보자.

타입 추론은 프로퍼티에 타입을 명시적으로 선언하지 않아도 컴파일러가 문맥에 따라 타입을 추론하는 것을 말한다.

img

위의 코드에서 String을 명시적으로 선언하지 않아도 컴파일러는 이를 String으로 추론한다. 좀 더 복잡한 예제 코드로 타입 추론을 활용하고, 컴파일러에서 타입 추론이 어떻게 동작하는지를 살펴보자.

img

위의 코드에서 FilteredList는 주어진 데이터를 리스트 형태로 보여주고 필터링 기능을 제공하는 재사용 가능한 뷰다. 이 FilteredList는 재사용 가능해야 하므로 기본적으로 생성자 인자들은 제네릭 해야 한다. 위의 코드에서 FilteredList를 사용하면서 따로 타입을 명시해 주지 않고 있다. 이는 컴파일러가 타입 추론을 하기 때문에 가능한 일인데, 이를 좀 더 알아보기 위해 FilteredList가 어떻게 정의되어 있는지 코드로 살펴보도록 하자

img

Element, FilterKey 그리고 RowContentFileteredList가 생성될 때 실제 타입, 즉 Concrete 타입으로 대체된다. 이제 선언부와 호출부를 나란히 두고 비교해보자.

img

제네릭 타입으로 인해 복잡한 선언부와 비교했을 때 호출부는 훨씬 깔끔한 코드임을 확인할 수 있다. 이는 컴파일러가 주어진 값들로 타입 추론을 하기 때문에 가능한 일인데, 타입 추론이 아닌 명시적으로 타입을 명시한다면 아래와 보다 복잡한 코드를 작성해야 한다.

글

그렇다면 컴파일러는 어떻게 타입 추론을 하는 것일까? 타입 추론은 일종의 퍼즐이라고 할 수 있다. 우린 퍼즐을 하면서 하나의 조각이 맞춰질 때 다음 조각을 자연스럽게 유추할 수 있다. 하나의 퍼즐이 맞춰질 때마다 다음 조각에 대한 단서를 우린 유추할 수 있다. 컴파일러는 이렇게 퍼즐을 풀 듯이 우리의 코드에서 단서를 찾아 퍼즐을 하나씩 맞춰가며 타입을 추론한다.

위의 코드를 사용해서 컴파일러가 어떻게 퍼즐을 맞춰가는지 살펴보자.

img

먼저 첫 번째로 인자로 넘기는 smoothies라는 단서를 통해 Element의 타입을 추론할 수 있다. smoothies[Smoothie] 타입으로 ElementSmoothie 타입으로 대체된다.

img

우린 Element라는 퍼즐 조각을 맞췄기 때문에 이를 통해 또 다른 단서를 얻을 수 있다. 바로 FilterKey다. \.title\Smoothe.title로 대체되고 Smoothietitle 프로퍼티는 String이란 것을 알 수 있기 때문에 FilterKeyString으로 대체된다.

img

img

RowContent 역시 ViewBuilder 클로저 안에서 SmoothieRowView가 반환되기 때문에 SmoothieRowView로 대체될 수 있다.

img

img

이런 식으로 컴파일러는 이전 단계의 단서를 통해 하나씩 타입을 추론해 나간다. 하지만 이렇게 얻은 이전 단계의 퍼즐 조각(단서)이 맞지 않는다면 소스 코드에 에러가 발생했다는 것을 의미한다. 즉 맞지 않은 타입이 들어갔기 때문에 컴파일러는 더 이상 타입 추론을 진행할 수 없다.

Smoothie.title이 아닌 Bool 타입의 Smoothie.isPopular로 바꿔보자

img

그렇다면 컴파일러는 Bool 타입을 FilterKey의 조각으로 사용할 것이다. 하지만 Bool 타입은 hasSubString(_:) 메서드가 없기 때문에 이후의 타입 추론을 진행할 수 없고, 에러를 뱉는다.

img

이렇게 스위프트 컴파일러는 추후에 에러 메시지를 출력할 때 사용하기 위해 에러 추적 기능을 타입 추론에 통합시켰다. 컴파일러는 타입 추론을 진행하면서 직면한 에러를 기록한다. 그리고 컴파일러는 에러를 고치고 타입 추론을 계속 진행하기 위해 휴리스틱을 사용한다.

그리고 타입 추론이 끝나면 컴파일러는 타입 추론을 진행하면서 수집한 에러를 actionable한 에러 메시지(자동으로 코드를 수정할 수 있는)나 에러를 발생시킨 실제 타입에 대한 메시지와 함께 개발자에게 알린다.

img

이렇게 통합된 에러 추적 시스템은 Xcode11.4의 스위프트 5.2에선 많은 오류 메시지에 도입되었고, Xcode12의 스위프트 5.3에선 모든 에러 메시지에 적용되었다. Embrace Swift type inference

OptionSet

오늘은 회사 과제를 진행하던 중 처음 접한 스위프트의 OptionSet이라는 친구를 살펴보려 한다. 처음 접한 개념이지만 알아보니 그렇게 어렵지 않은 개념이면서도 유용하게 사용할 수 있을 것 같은 개념이어서 이렇게 기록해보려 한다.


먼저 그 개념을 공식 문서를 통해 살펴보도록 하자.

OptionSet 프로토콜은 비트들 각각이 집합의 요소를 표현하는 비트 집합 타입을 표현하는데 사용된다. 이 프로토콜을 채택한 사용자 정의 타입에선 요소 검사(해당 요소가 집합에 속하는지), 합집합, 교집합 연산과 같은 집한 연산들을 수행할 수 있다.

옵션 집합(OptionSet 프로토콜을 채택한 사용자 정의 타입)을 만들기 위해선 타입 선언 부분에 rawValue를 포함시켜야 한다. 사용자 정의 타입으로 만든 옵션 집합이 기본 집합 연산을 수행하기 위해선 rawValue 프로퍼티는 반드시 FixedWidthInteger 프로토콜을 따르고 있는 타입(Int, UInt8 등등)이어야 한다. 다음으로는 정적(static) 변수로 고유한 2의 거듭제곱 값(1, 2, 4, 8, …)을 rawValue로 갖는 옵션들을 생성한다. 이렇게 2의 거듭제곱 값을 rawValue로 가져야 각각의 옵션들은 단일 비트로 표현이 가능하기 때문이다.

OptionSet은 다음과 같이 정의할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
struct ShippingOptions: OptionSet {
let rawValue: Int

static let nextDay = ShippingOptions(rawValue: 1 << 0) // 0001
static let secondDay = ShippingOptions(rawValue: 1 << 1) // 0010
static let priority = ShippingOptions(rawValue: 1 << 2) // 0100
static let standard = ShippingOptions(rawValue: 1 << 3) // 1000

static let express: ShippingOptions = [.nextDay, .secondDay] // ??
static let all: ShippingOptions = [.express, .priority, .standard] // ??
}

먼저 왜 2의 거듭제곱 값으로 표현되어야 할까?

그 이유는 위에서 언급되었듯이 단일 비트로 각각의 값을 표현할 수 있기 때문이다. (Bitmask)

1
2
3
4
0001 // 1
0010 // 2
0100 // 4
1000 // 8

만일 2의 거듭제곱이 아니라면 어떻게 될까? 이에 대해서는 밑에서 얘기해보자.

처음 OptionSet을 봤을 때 가장 먼저 든 생각은 *”열거형(enum)이랑 뭐가 다른거지!?”* 였다. 그 생각을 몇몇의 글들을 읽어보면서 정리해보았다.

열거형과 OptionSet을 채택한 타입과의 가장 큰 차이점은 단일 타입 변수가 가질 수 있는 경우의 수의 차이다. 열거형 타입은 해당 타입의 케이스를 하나만 나타낼 수 있다. 하지만 OptionSet 여러 케이스를 하나의 변수로 표현할 수 있다. 열거형에서 이를 표현하려면 여러 케이스에 해당하는 열거형 타입을 배열과 같은 콜렉션 타입으로 표현해야 한다.

OptionSet은 여러 케이스를 하나의 변수로 담을 수 있기 때문에 각각의 케이스는 유일해야 한다. 그리고 이를 위해 우리는 비트 값으로 이를 표현한 것이고 2의 거듭제곱으로 표현한 것도 그와 같은 이유다. 실제로 여러 케이스를 표현한다고 여러 값을 갖고 있을 필요는 없다. 위의 ShippingOptions을 살펴보자.

express[.nextDay, .secondDay]로 표현된다. 하지만 [ShippingOptions]이 아닌 ShippingOptions 타입 변수에 할당된다. 2의 거듭제곱으로 표현되고 있다는 걸 상기시켜보면 실제로 express의 값은 .nextDay(0001).secondDay(0010)을 더한 0011인 것이다. 그리고 모든 원시 값이 2의 거듭제곱이기 때문에 0011만 보아도 00010010의 조합인 걸 알 수 있다.

여기서 2의 거듭제곱으로 해야 하는 이유가 나온다. 만일 2의 거듭제곱 값이 아닌 0011 , 즉 3을 원시 값으로 갖는 케이스가 있다면 .express와 구분이 되지 않기 때문이다.

또한 서버에 값을 전송할 때 리스트 형태의 값을 전달하는 것보다 이렇게 비트 마스크로 표현된 값을 보내는 것이 더 수월할 수 있다. (물론 서버 개발자와의 확실한 상호 협의가 요구되지만)

그리고 위에서 언급했듯이 OptionSet을 따르는 프로토콜은 집합 연산을 수행할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
let normal: Pet = [.nextDay, .secondDay]
let options2: Pet = [.nextDay, .priority, .standard]
let intersection = options1.intersection(options2)
print(intersection) // ShippingOptions(rawValue: 1) 👉 nextDay

let union = options1.union(options2)
print(union) // ShippingOptions(rawValue: 15) 👉 nextDay, secondDay, priority & standard

let subtracting = options1.subtracting(options2)
print(subtracting) // ShippingOptions(rawValue: 2) 👉 secondDay

let contains = options1.contains(.standard)
print(contains) // false

오늘은 이렇게 간단히 OptionSet에 대해 알아보았다. 이를 사용한다면 보다 여러 케이스를 포함하는 상황에서 보다 적은 코드로 각각의 상황에 대응할 수 있을 것 같다.

init?(coder:), init(nibName:, bundle:), awakeFromNib()

평소에 뷰 컨트롤러나 뷰를 만들기 위해 스토리보드뿐만 아니라 .xib나 코드의 형태로 만들어 사용하기도 한다. 하지만 이들 각각의 방법으로 뷰 컨트롤러를 생성할 때 생성되는 시점이나 불리는 메소드를 명확히 알지 못해 매번 작성한 코드를 이리저리 움직여가며 동작을 확인했다. 오늘은 이렇게 정확하기 알지 못했던 개념을 개념을 더욱 명확하게 하기 위해 공부하고 기록한 내용이다.


xib? nib?

  • nib - NeXT Interface Builder
  • xib - XML Interface Builder

.xib.nib의 차이점은 무엇일까? 차이점이라기보단 .xib 파일은 빌드 시점에 .nib 파일의 형태로 바뀐다. 인터페이스 빌더로 작업한 UI는 .xib 파일의 형태로 저장되고 빌드 시점에 앱 번들로 복사되고 런타임에 로드된다. 또한 .xib 파일은 텍스트 기반의 파일로 바이너리 파일인 .nib 파일보다 소스 컨트롤에 용이하며 읽기에 용이하다.

init?(coder:)

xib 파일로 만든 뷰는 실제로 저장될 때 아카이빙 되어 저장된다. 그러므로 해당 뷰를 불러올 때는 언아카이빙를 통해 불러와야 한다. 스토리보드도 내부적으론 일종의 xib 파일들로 이루어져 있기 때문에 로그를 찍어보면 init?(coder:) 생성자가 불리는 것을 확인할 수 있다. 하지만 아직 이 시점에선 @IBOutlet이나 @IBAction은 준비되어 있지 않다.

init(nibName:, bundle:)

nib 파일명은 결국 우리가 생성해준 xib 파일이 바뀐 결과물이기 때문에 이 둘의 이름은 확장자를 제외하곤 동일하다.

init(nibName:, bundle:) 생성자는 코드로 nib 파일로 만들어진 뷰를 불러올 때 사용하는 생성자이다. 이는 본인도 적지 않게 사용해봤다. 사용법은 nibName 아규먼트에는 nib파일의 이름에 해당하는 값을 넣어주는데 본인은 언제나 두 아규먼트 모두에 nil 값을 할당해주었다. 그 이유가 궁금해서 찾아봤는데 공식 문서는 이를 다음과 같이 설명하고 있었다.

nibName

이 프로퍼티는 init(nibName:, bundle:) 생성자에 의해 생성되는 시점에 지정된 값을 포함한다. 이 프로퍼티는 nil일 수 있다.

뷰 컨트롤러의 뷰를 저장하기 위해 nib 파일을 사용한다면 뷰 컨트롤러를 생성하는 시점에 생성자에 해당 이름을 명시해주는 것을 추천한다. 하지만 만일 nib 파일 명을 지정해주지 않고 loadView를 오버라이딩하지 않는다면 뷰 컨트롤러는 xib 파일을 다른 방법을 이용해 이를 찾아낼 것이다. 구체적으로 말하자면 적절한 파일 이름 (.nib 확장자를 제외한)의 nib를 찾고 뷰를 필요로 할 때 이를 로드하여 사용할 것이다. 적절한 이름을 찾는 과정은 다음의 순서를 따라 찾는다.

  1. 만일 뷰 컨트롤러 클래스의 이름이 MyViewController와 같이 Controller 끝난다면 Controller를 제외한 MyView.nib 파일을 찾을 것이다.
  2. 뷰 컨트롤러와 이름이 동일한 nib 파일을 찾는다. MyViewController라면 MyViewController.nib

위와 같은 이유로 생성 시점에 아규먼트를 넘겨줘야 한다면 다음과 같이 생성자를 정의해줄 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class MyViewController: UIViewController {
private var prop: Int

init(prop: Int) {
self.prop = prop
super.init(nibBundle: nil, bundle: nil)
}

// 사용자 정의 생성자를 작성해주었기 때문에 반드시 required 생성자를 구현해주어야 한다.
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
}

awakFromNib()

사실 awakeFromNib()는 생성자가 아니다. 근데 생각보다 이를 생성자라고 생각하고 사용하는 경우가 적지 않은데 이를 잘못 이해한다면 원하는 결과물을 얻을 수 없을 것이다. 그럼 awakeFromNib은 무엇일까?

이 메소드는 init?(coder:)를 통해 뷰가 모두 언아카이빙된 후 호출된다. @IBOulet@IBAction이 모두 자리가 잡힌 후 호출되는 것이다. init?(coder:)가 언아카이빙의 시작점이라면 awakeFromNib()은 끝나는 시점이라고 할 수 있다.

이 세 메소드들을 공부하면서 각각 로그를 찍어보았는데 한곳에서 알 수 없는 현상이 발생하였다. 이는 다음과 같은 상황이었다. xib 파일을 하나 만들고 UIViewController를 상속받는 뷰 컨트롤러를 생성하였고 이를 xib 파일의 File's Owner로 지정하였다. 그리고 뷰 컨트롤러의 awakeFromNib() 메소드에 로그를 출력하도록 코드를 추가해보았지만 awakeFromNib()은 호출되지 않았다.

그 이유를 찾아본 결과 File's Owner는 이렇게 언아카이빙 되는 뷰 객체와 관련성이 없다. 위에서 언급했듯이 awakeFromNib() 언아카이빙이 모두 끝난 후 호출되는 메소드이다. File's Owner는 언아카이빙이 시작되기 전에 이미 존재하고 언아카이빙이 모두 끝난 후 해당 객체와 연결되기 때문에 File's Owner인 뷰 컨트롤러 안에서의 awakeFromNib()의 호출은 무의미하다.

하지만 스토리보드와 뷰 컨트롤러에선 얘기가 좀 달라진다. 스토리보드에 뷰 컨트롤러를 올리고 로그를 찍어보면 정상적으로 찍히는 것을 확인할 수 있다.

1
2
ViewController - init(coder:)
ViewController - awakeFromNib

그 이유는 해당 뷰 컨트롤러 객체 클래스를 File's Owner가 아닌 해당 뷰 컨트롤러 자체이기 때문이다. 그렇기 때문에 스토리보드 위에 올라가있는 뷰 컨트롤러는 언아키이빙 과정을 거치기 때문에 정상적으로 awakeFromNib()이 호출되는 것을 확인할 수 있다.


참고자료

  1. iOS: initWithCoder:, initWithNibName:bundle:, awakeFromNib, loadView
  2. What’s up with the .NIB -> .XIB?