-
WWDC2020 - Unsafe Swift앱등이에게 살충제를 뿌린다./Swift 2021. 5. 14. 18:41
Unsafe Swift
Swift에서 제공하는 많은 타입, 프로토콜, 프로퍼티등
그 중 Unsafe라는 접두어를 가진 것들이 있다.
무엇이 다른 것일까?
- 수행하는 기능, 인터페이스에서 큰 차이점을 가지지는 않는다.
- Invalid Input을 처리하는 과정에서 차이점을 가진다.
대부분의 Operator가 Input을 완전히 Validation한다. (Swift가 Safe한 Programming Language인 이유)
그래서 우리는 쉽게 에러를 리포트받고 수정할 수 있다.
Example
Optional을 예로 들어보자.
let value: Int? = nil print(value!) // Fatal error: Unexpectedly found nil while unwrapping an Optional value
Optional은 unwarpping을 할 때, input이 nil이 아니어야 한다는 명백한 조건을 가지고 있다.
따라서 input이 nil이라면 unwarpping을 하는 순간에 알아보기 쉬운 런타임 에러가 발생한다.
input이 nil인 상태로 unwrapping을 시도하는 것 자체가 잘못된 시도이고 에러이지만, 결과가 명백하여 우리는 이를 수정하기 쉽다.
Force unwrap은 Safe하다고 할 수 있다.
요구사항에 만족하지 않는 input(nil)을 포함한 모든 input에 대한 결과를 예측할 수 있기 때문이다.
Unsafe한 Operation은 요구사항을 만족하지 않는 Input에 대해서 예측할 수 없는 결과를 가져온다.
let value: String? = "Hello" print(value.unsafelyUnwrapped) // Hello
옵셔널도 unsafelyUnwrapped라는 Unsafe Operator를 제공한다.
이 Operator도 마찬가지로 Input이 nil이라는 조건을 가지고 있다.
하지만 Optionzation을 활성화 시킨 뒤 컴파일을 하면, unwrapping과정에서 nil검증을 하지 않는다.
개발자가 non-nil 값을 넣어주었을 거라고 가정하고 바로 값에 접근한다.
???
Input이 nil인데 값에 접근해서 가져온다고?
결과는 어떻게 될까?
크래쉬가 발생하거나 Garbage Value를 리턴한다.
실행할 때마다 같은 결과가 발생할 수도 있고, 매번 다른 결과를 가져올 수도 있다.
요점은 Unsafe Operator를 사용할 때, Input을 무조건 만족시켜줘야 하는 책임이 있다는 것이다.
이를 어겨서 에러가 발생했을 때, 결과를 예측할 수 없고 이는 디버깅을 힘들게 만든다.
Unsafe API를 사용하는 케이스는 두 가지로 구분된다.
- C, Objective-C와의 호환을 위해서
- 성능에서 이점을 얻기 위해서
위에서 예로 들은 Optional은 2번에 해당한다.
Unwrapping을 할 때, nil체크를 하지 않기 때문에 성능에서 미세하나마 이득을 가져갈 수 있다.
Safe API를 사용하는 목적은 크래쉬 방지가 아니다.
오히려 반대다.
크래쉬를 명백하게 발생시키고 개발자가 쉽게 상황을 이해하고 수정할 수 있게 하는 것이다.
Swift가 Safe한 프로그래밍 언어인 이유는 Input을 완전히 Validate하기 때문이다.
이 기능을 하지 못하는 모든 Construct들이 Unsafe로 표시되어 있다.
Unsafe Pointers
스위프트에서는 C언어의 포인터에 해당하는 포인터타입을 제공한다.
포인터를 이야기해야 하니 메모리에 대해서 잠깐 짚고 넘어가자.
앱을 실행하면 아래와 같은 메모리 구조를 상상할 수 있다.
메모리에 올라간 데이터의 주소는 앱을 사용하는 내내 계속 변하거나 사라지게 된다.
이 메모리 관리를 Swift가 알아서 해주기 때문에 우리는 신경쓰지 않고 있다.
메모리를 직접 관리할 수있게 도와주는 것이 Unsafe pointer이다.
- Int값을 저장하기 위한 메모리 공간을 할당한다.
- 메모리 관리를 Swift가 해주지 않는다. 개발자가 직접 해야한다.
- 사용을 마치면 deallocate를 메모리에서 해제해준다.
- 5번 라인에서 ptr은 dangling pointer가 되었기 때문에 에러가 발생한다.
어떤 에러가 발생할까?
운이 좋다면 ptr이 가리키는 주소에 아무것도 없어서 크래쉬가 발생할 수도 있다.
하지만 알 수 없는 다른 데이터가 존재하는 상황이라면 상황이 심각해질 것이다.
어디선가 사용하기 위해 올려놓은 데이터에 overwrite를 하게 될 것이다.
포인터가 그렇게 위험하다면 이걸 왜 쓰는 것일까??
우선 C, Objective-C와의 호환을 위해서 사용한다.
위 표는 C의 포인터에 해당하는 Swift의 포인터타입을 매핑한 것이다.
예를 들어 C의 함수를 스위프트에 import하면 이렇게 된다.
// C: void process_integers(const int *start, size_t count); // Swift: func process_integers(_ start: UnsafePointer<CInt>!, _ count: Int)
let start = UnsafeMutablePointer<CInt>.allocate(capacity: 4) start.intialize(to: 0) (start + 1)..intialize(to: 2) (start + 2)..intialize(to: 4) (start + 3)..intialize(to: 6) process_integers(start, 4) start.deinitialize(count: 4) start.deallocate()
- UnsafeMutablePointer의 이니셜라이저로 Int용 다이나믹 버퍼를 생성한다.
- initialize메소드를 사용하여 버퍼에 값을 채워줄 수 있다.
- 이 포인터 값으로 C 함수를 호출할 수 있다.
- 포인터의 상태를 deinitialize로 만들고, 메모리를 해제하기 위해 deallocate를 수행했다.
이 모든 과정이 unsafe하게 이루어 지고 있다.
1번 라인에서 생성한 포인터의 Lifetime이 전혀 관리되지 않고 있다.
그래서 적절한 순간에 deinitailize하고 deallocate해주어야 한다. 안그러면 메모리릭이 발생할 것
initialize하는 과정에서 사용하는 메모리주소가 우리가 선언한 버퍼 범위 이내인지 확인하지 않는다.
마찬가지로 예측할 수 없는 에러의 가능성이 있다.
버퍼를 생성하는 과정을 개선해보자.
여기서 생성한 버퍼포인터는 시작 주소값을 가지고 있고, length(4)가 코드 여기저기에서 복붙되어 사용되고 있다.
주소값과 length를 쌍으로 가지는 버퍼를 생성하면 버퍼의 Boundary를 쉽게 알 수 있게 되고 out-of-bounds체크가 쉬워지게 된다.
Swift에서는 이러한 기능을 제공하는 타입이 있다.
UnsafeBufferPointer<Element> UnsafeMutableBufferPointer<Element> UnsafeRawBufferPointer<Element> UnsafeMutableRawBufferPointer<Element>
Unoptimized빌드에서 이 타입의 버퍼포인터는 out-of-bounds access를 체크한다. 그래서 조금이나마 안전하다고 할 수 있겠다.
Sequence.withContiguousStorageIfAvailable(_:) MutableCollection.withContiguousMutableStorageIfAvailable(_:) String.withCString(_:) String.withUTF8(_:) Array.withUnsafeBytes(_:) Array.withUnsafeBufferPointer(_:) Array.withUnsafeMutableBytes(_:) Array.withUnsafeMutableBufferPointer(_:)
Swift의 Collection에서는 위와같이 버퍼 포인터를 사용하여 버퍼에 접근할 수 있다.
// C: void process_integers(const int *start, size_t count); // Swift: let values: [CInt] = [0, 2, 4, 6] values.withUnsafeBufferPointer { buffer in print_integers(buffer.baseAddress!, buffer.count) }
withUnsafeBufferPointer를 사용함으로써 unsafe한 영역을 클로져 내부로 최소화 할 수 있다.
물론 여기서 buffer를 escape시킨다면 메모리 침범 등의 부작용이 있을 수 있으니 주의해야 한다.
// C: void process_integers(const int *start, size_t count); // Swift: let values: [CInt] = [0, 2, 4, 6] print_integers(values, values.count)
근데 사실 Swift에서 포인터를 사용하여 C 함수를 자주 호출하기 때문에, Swift는 Syntax를 제공한다.
그냥 Array를 전달하면 컴파일러가 알아서 필요한 포인터로 변환해준다.
Summary
- Unsafe API를 사용하려면 이 API들의 요구사항을 정확히 파악하고 사용해야ㅕ 한다.
- 따라서 사용을 최소화 하는 것이 좋다.
- 메모리에 접근할 때, 단순한 포인터 값을 사용하지 말고 UnsafeBufferPointer를 사용하자.
- Xcode의 Address Sanitizer를 사용하면 Unsafe API를 디버깅하기 좋다.
Reference
https://developer.apple.com/videos/play/wwdc2020/10648/
https://www.raywenderlich.com/7181017-unsafe-swift-using-pointers-and-interacting-with-c
'앱등이에게 살충제를 뿌린다. > Swift' 카테고리의 다른 글
[Swift] addObserver를 한 뒤 remove를 해주어야 하는가? (0) 2022.09.18 [RxSwift] Rx Observer들의 기본 스케쥴러 (0) 2020.09.11 [RxSwift/RxCocoa] RxCocoa에서 TableView사용하는 코드를 쪼개서 이해해보자. (0) 2020.03.15 [RxSwift/RxCocoa] Subject는 알겠는데, Relay는 뭐지? (0) 2020.03.14 Swiftlint를 통해서 프로젝트에서 느낌표(!)를 제거해보자 (0) 2019.12.19