Swift에서 C 형식의 포인터를 이용하자, 그리고 Unsafe란?
스위프트 언어에서는 오브젝티브C와 C에 대한 호환성을 제공합니다. 이를 상호운용(interoperability)이라고 합니다. 그런데, C 계열의 언어들에서는 포인터가 자주 사용되지만, Swift에서는 그것과 상응하는 개념이 없는 것 처럼 보입니다.1비슷한 개념 중 하나는 inout입니다. 다만, inout은 기본적으로는 copy-in, copy-out(함수에 값을 복사하여 전달 한 후에, 함수가 종료될 때 복사된 값을 원래 변수에 할당함.)이고, 최적화의 결과로 포인터처럼 행동합니다. 자세한 내역은 https://docs.swift.org/swift-book/ReferenceManual/Declarations.html#ID545 In-Out Parameters 참조
다행히도, Swift에서 다음과 같은 Structure를 이용한 C의 Pointer에 사상되는 개념을 제공합니다. 이번 글에서는 포인터 구조체를 어떻게 사용할 수 있을지 간단하게 알아봅니다.
목차
일단 포인터를 C와 호환해서 사용해보기
Pointer structure를 이용해서 C 계열 언어의 포인터와 상호 호환할 수 있습니다. 아래처럼요.
일단 어떻게 상호호환할 수 있는지 한 번 볼까요? 오브젝티브C, C와 Swift 호환에 대한 간략한 문서를 이전에 적어두었으니 참조하면 더 좋을 것 같습니다. (-> Swift는 어떻게 빌드되고, Objective-C와 어떻게 같이 쓰이는가?)
Xcode에서 C파일을 만들면, c 파일과 헤더가 생성되고, 자동으로 Bridging-Header가 생성됩니다.
헤더에 우리가 만들 함수의 인터페이스를 정의한 후에, C 파일에 구현을 작성합니다.
호환만 확인할 거라서 print 문만 추가하였어요.
마지막으로 Bridging-Header에 C 헤더를 추가하면 Swift에서 C 코드를 불러올 수 있어요.
콘솔에 C 언어에서 작성한 함수가 실행되어 표준 입출력을 이용해 글자가 출력되는 것을 확인할 수 있습니다.
아주 잘 상호호환이 되는 것을 확인할 수 있었습니다. Swift의 포인터 관련 구조체들을 어떻게 쓰는 지는 간단하게 실습해봤으니까, Swift 포인터 구조체들을 어떻게 사용하는지 더 알아봅시다.
Swift 포인터에 대한 간단한 설명
스위프트로 작성한 일반적인 프로그램의 메모리 할당 구조입니다. code, data, heap, stack, linked binaries, resources 구조가 보이네요. 일반적으로 스위프트 프로그램을 작성할 때 이런 할당은 자동으로 이루어집니다. 할당을 조금 자유롭게 할 때, Unsafe pointer를 써서 할당할 수 있다고 생각할 수 있겠습니다.
위와 같이 Swift에서 포인터 개념을 UnsafePointer 구조체를 이용해서 사용할 수 있어요. allocate, deallocate가 있는 것으로 보아 기본적으로 heap에 할당이 되지만(포인터를 넘겨주어 다른 함수에서 참조가 가능합니다.), 항상 그런 것은 아닌 것 같습니다. 경우에 따라 stack에 할당 가능한 경우에는 컴파일러가 최적화를 해주는 듯해요. (https://forums.swift.org/t/unsafemutablepointer-on-the-stack/4167/2)
위의 코드를 보면, deallocate()를 한 다음에 pointee에 값을 저장해주고 있습니다. 이러한 동작은 상정되지 않은 동작이기 때문에 문제를 일으킬 수 있습니다. (잘 못된 주소에 억세스하여 크래시가 나거나, 정상적인 값을 잘 못된 값으로 덮어씌우는 등의 문제를 일으킬 수 있어요.) 다만 테스트 코드에서는 메모리 가용 공간이 많은 탓인지, 별 다른 문제는 없었습니다.
포인터를 사용하는 간단한 방법을 알아봤습니다. Safely Manage Pointers in Swift (WWDC20)에서는 좀 더 깊고, 실용적인(?) 예시들을 알려주는데요. 이는 나중 문서에서 정리해볼 예정입니다.
스위프트 포인터와 C 포인터 호환
Swift pointer 구조체와 C 포인터가 위의 표와 같이 상호호환이 됩니다. 다시 한번, C의 코드를 스위프트에서 이용하는 예제를 참조해봅시다.
기본적으로 포인터 구조체를 사용하게 되면, 힙에 할당하는 것을 상정합니다. 따라서, start 변수와 실제 공간 할당의 생명주기가 일치하지 않을 수 있습니다. 포인터를 이용해서 메모리를 할당한 후에는 꼭, 메모리를 해제해 주어야 메모리 누수가 발생하지 않습니다.
연속된 메모리 공간에 할당하는 객체들의 포인터를 얻기 위해 위와 같은 함수를 제공해줍니다. 포인터가 해당 함수의 인자로 받는 클로저 영역에서만 유효하고, 할당을 신경쓸 필요가 없으므로 더욱 안전합니다. 예제 코드는 다음과 같습니다.
이런 식으로 클로저를 이용할 수도 있고,
공간이 연속된 객체와 크기를 넘겨줄 수도 있습니다. (위의 클로저 코드로 컴파일러가 변환해줍니다.)
명시적으로 포인터를 넘기는 방법과 바로 연속된 공간을 가지는 객체를 넘겨서 암시적으로 해당 코드를 만들어주는 방법은 위의 표와 같이 대응됩니다.
또한, 초기화와 동기에 클로저를 통해 포인터 구조체를 받을 수 있습니다.
일반적인 변수의 경우에도 클로저를 통해 자동으로 라이프 타임을 관리할 수 있고, 참조 관리 문제로 인해 그렇게 사용하기를 권장합니다. 특히, UnsafeMutablePointer를 inout argument를 통해 초기화할 경우, Pointer의 생명보다 값의 생명이 긴 것이 보장되지 않으므로, dangling pointer(허상 포인터)가 될 수 있고, xcode가 경고를 표시합니다.
사례: 시스템 함수 호출
사용례 중 하나로, C로 작성된 시스템 함수를 호출할 수 있습니다. cache line의 크기를 알아보는 함수입니다. (그런데, M1 맥이라서 그런지, 정상적으로 128이라는 숫자가 호출되지 않고 0이 호출되네요. 나중에 원인을 찾아봐야할 것 같습니다.)
Unsafe 하다는 것은 무엇인가?
위에서 Pointer를 이용해서 C와 상호호환하는 코드를 알아보았고, 작성해보았습니다. 그런데, 왜 Swift에서 Pointer 구조체에 Unsafe라는 키워드를 적어놓았을까요? 안전하지 않다는 것은 무슨 의미일까요?
스위프트에서 Safe와 Unsafe는 함수를 호출했을 때, 모든 입력값에 대해서 행동이 정의되어있냐, 아니냐로 구분이됩니다. 예를 들어서, nullable을 생각해보면, 널러블 객체를 만든 후에 forced wrapping 하는 경우가 있습니다. 다들 아시다 시피, 갚이 nil이 아니면 값을 리턴하고, 아니면 fatal 런타임 에러를 냅니다.
이때, nil 일 때는 fatal runtime error를 발생시켜 앱이 크래시되므로 nullable은 안전하지 않은 걸까요? 그렇지 않습니다. 왜냐하면 스위프트는 해당 값이 nil인지 아닌지, 검사를 했고, 단순히 nil 이기 때문에 친절하게 원인까지 적어가면서 error를 일으켰기 때문입니다. 안전한 오퍼레이션이란, 모든 입력값에 대해 정의된 행동을 하기 때문에, forced wrapping은 크래시를 낼지언정 safe하지 않은 것은 아닙니다. 반대로 safe 하지 않은 함수는 어떤 특정 값에만 행동이 보장된 함수를 의미합니다.
위의 사진과 같이 forced unwrapping은 명확한 이유를 알려주며 fatal error를 내지만, unsafelyUnwrapped를 이용하는 경우 에러 없이 실행됩니다. 경우에 따라서 쓰레기값, 메모리 참조 오류로 인한 크래시 등 다양한 행동을 할 수 있겠죠. (debug build에서는 둘 다 검증해주니, release build로 테스트해야합니다.)
그러면 왜 Unsafe한 기능들이 있는 거고, 써야할까요? 다음과 같은 이유가 있습니다.
- 위에서 알아본 것과 같이 C 또는 Objective-C와의 호환을 위해서 스위프트가 안전을 보장하지는 않는 타입들을 써야합니다.
- 런타임 속도를 최적화하기 위해서 쓸 수도 있습니다.
정리하면,
- 안전한 코드는 모든 입력값에 대해 행동이 정의되어 있는 코드입니다.
행동은 정상 작동, error(크래시 리포트가 만들어질 수 있습니다.) 등을 포함합니다. 따라서 문제를 파악하고 디버깅하기가 쉽습니다. - 안전하지 않은 코드는 특정 입력값에 대해서만 행동이 정의되어 있는 코드입니다. 작성자가 고려하지 않은 입력에 대해서는 정상적인 행동을 보장하지 않습니다.
잘 작동하는 것 처럼보일 수도 있고, 당장에 에러가 날 수도 있고, 컴파일러 버전이 업데이트되면서 동작이 바뀔 수도 있습니다. 문제를 파악하기 어렵습니다. - Unsafe나, Unmanaged가 붙지 않은 스위프트 기능은 Safe합니다.
뭔가 궁금해서 테스트해본 것
포인터 구조체를 보면서 궁금했던 점은, 실제 객체를 생성할 때, 우리가 간단하게 객체를 초기화하지만, 내부적인 SIL은 저 포인터 구조체를 이용하는 게 아닐까 궁금했습니다. 결론 부터 말하면, 저걸 그대로 쓰는 건 아닌 것 같아요. Int나 Array등의 구현에 포인터 구조체가 쓰이긴 하지만, 내부 값을 관리하는 용도로만 확인이 됩니다. (제일 겉의 SIL코드가 아닌 어딘가 깊숙한 곳에서는 모르겠지만, 당장에 보이지는 않네요.)
Optional binding을 통한 unwrapping, forced unwrapping, unsafelyUnwrapped는 속도 차이가 얼마나 날까요? 위에서 설명한 것과 같다면 순서대로 속도가 빠를 거 같은데 정말로 그럴까요? (release build)
실험을 위해 최적화를 끄고, 다음과 같이 시간을 측정해봤는데요. 다른 오버헤드가 더 큰 건지, 크게 차이는 없는 것 처럼 보입니다. (여러번 돌리면 나열한 순서대로 속도가 빠른 것 같기는 한데, 워낙 측정 시마다 변화가 커서 결론으로 삼기에는 애매한 점이 있네요.)
참고
https://www.raywenderlich.com/7181017-unsafe-swift-using-pointers-and-interacting-with-c
https://developer.apple.com/videos/play/wwdc2020/10648/
아직 업데이트 중