Skip to content
This repository has been archived by the owner on Sep 8, 2023. It is now read-only.

Latest commit

 

History

History
331 lines (239 loc) · 13.4 KB

2019-01-31-void.md

File metadata and controls

331 lines (239 loc) · 13.4 KB
title author translator category excerpt status
Void
Mattt
김필권
Swift
`Void` 는 어떠한 멤버(메소드, 값, 심지어 이름까지)도 가지지 않습니다. 그저 `nil` 에 지나지 않습니다.
swift
4.2

무(無)와 NSHipster의 관계는 Objective-C의 nil 에 대한 첫 번째 글부터 Swift의 Never를 다룬 최근 글까지 이어집니다. 이 글은 지금까지의 무에 대한 글 중에서도 제일 큰 공간 공포를 느끼게 해줄 글이 될 것입니다. 오늘은 Swift의 Void 에 대해 알아보겠습니다.


Void 란 무엇일까요? Swift의 Void는 아무 내용도 없는 튜플을 의미합니다.

typealias Void = ()

Void 를 코드에 사용해보면 왜인지 알게 됩니다.

let void: Void = ()
void. // 자동완성이 나오지 않습니다

Void 는 어떠한 멤버(메소드, 값, 심지어 이름까지)도 가지지 않습니다. 그저 nil 에 지나지 않습니다. 빈 용기에 Xcode가 할 수 있는 일은 없습니다.

무(無)를 위한 무언가

아마 가장 궁금해하실 Void 타입의 대표적인 쓰임새는 표준 라이브러리의 ExpressibleByNilLiteral 프로토콜에서 찾을 수 있습니다.

protocol ExpressibleByNilLiteral {
    init(nilLiteral: ())
}

ExpressibleByNilLiteral 프로토콜을 따르는 타입은 nil 리터럴로 초기화될 수 있습니다. 대부분의 타입들은 이 프로토콜을 따르지 않고, nil의 부재를 보충하기 위해 Optional 을 사용합니다. 하지만 가끔은 만나게 될 것입니다.

ExpressibleByNilLiteral 를 사용해서 초기화 할 때 실제로는 아무 인자도 받지 않습니다. (만약 그랬다면 그것은 누구를 위한 값일까요?) 하지만 필수값이 있는데 init() 과 같이 아무값도 넣지 않을 수는 없습니다. 이것은 많은 타입의 init 함수와 똑같이 생겼기 때문입니다.

nil 인스턴스를 반환하는 타입 메소드를 만들어서 넘기는 방법을 시도해볼 수 있지만 내부의 필수 상태들은 init 바깥에서 접근하지 못하는 경우가 많습니다. 다행히 더 나은 해결책이 존재합니다. 그리고 그 중 하나를 오늘 적용해보겠습니다. 그것은 바로 Void 인자에 nilLiteral 레이블을 추가하는 것입니다. 이는 색다른 결과를 성취하기 위해 기존에 존재하는 기능을 사용한 기발한 방식입니다.

존재하지 않으면 비교할 수 없다

메타 타입 (예: Int.self 를 호출한 결과인 Int.Type), 함수 타입 (예: (String) -> Bool), Existential (예: Encodable & Decodable) 등으로 이루어진 튜플은 non-nominal 타입으로 구성되어 있습니다. Swift 대부분을 이루는 nominal 타입들과 대조적으로 non-nominal 타입들은 다른 타입들과의 관계로 정의됩니다.

non-nominal 타입들은 확장할 수 없습니다. Void 는 빈 튜플이고 튜플은 non-nominal 타입이기 때문에 우리는 Void에 프로퍼티를 따르는 어떠한 메소드나 프로퍼티를 추가할 수 없습니다.

extension Void {} // non-nominal 타입인 'Void'는 확장될 수 없습니다

VoidEquatable 을 따르지 않습니다. 그럴 수 없죠. 하지만 "같다" 연산자 (==)를 사용하면 우리가 예상한 대로 작동합니다.

void == void // true

이 분명한 모순 두 개를 공식 프로토콜 바깥에서 전역 free 함수로 조화시켜 보겠습니다.

func == (lhs: (), rhs: ()) -> Bool {
    return true
}

같은 방식을 "작다" 연산자 (<)에도 적용할 수 있습니다. 그리고 이를 Comparable 프로토콜 대신에 사용합니다.

func < (lhs: (), rhs: ()) -> Bool {
    return false
}

{% info %} Swift 표준 라이브러리는 arity 또는 사이즈로 이루어진 튜플을 6개까지 비교할 수 있는 구현을 제공합니다. 그러나 이것은 약간의 꼼수로 간주됩니다. Swift 코어 팀은 튜플을 위한 공식 Equatable 을 추가하는 것에 흥미를 표현해왔지만 실제로 작성될 때는 어떠한 공식적인 제안도 토론되지 않았습니다. {% endinfo %}

Ghost in the Shell

Void 는 Non-nominal 타입이기 때문에 확장될 수 없습니다. 하지만 Void 가 타입이라는 사실은 변함이 없으니 generic constraint로는 사용이 가능합니다.

예를 들어서 이 제네릭 컨테이너를 하나의 값이라고 생각해보겠습니다.

struct Wrapper<Value> {
    let value: Value
}

먼저 conditional conformance의 도움을 받아보겠습니다. 틀림없이 Swift 4.1의 가장 멋진 기능은 값 자신이 Equatable 을 따를 때만 WrapperEquatable 을 확장할 수 있는 기능이라고 생각합니다.

extension Wrapper: Equatable where Value: Equatable {
    static func ==(lhs: Wrapper<Value>, rhs: Wrapper<Value>) -> Bool {
        return lhs.value == rhs.value
    }
}

아까의 요령을 그대로 사용하면 우리는 최상위 단계에서 Wrapper<Void> 를 인자로 받는 == 함수를 구현해서 Equatable 을 흉내낼 수 있습니다.

func ==(lhs: Wrapper<Void>, rhs: Wrapper<Void>) -> Bool {
    return true
}

이렇게 하면 Void 값을 가지는 두 개의 래퍼를 성공적으로 비교할 수 있게 됩니다.

Wrapper(value: void) == Wrapper(value: void) // true

하지만 래핑된 값을 변수에 할당하려고 시도하면 컴파일러가 이상한 에러를 뱉을 것입니다.

let wrapperOfVoid = Wrapper<Void>(value: void)
// 👻 error: Couldn't apply expression side effects :
// Couldn't dematerialize wrapperOfVoid: corresponding symbol wasn't found

Void 의 공포가 다시 한번 몰아치네요.

팬텀 타입

non-nominal한 이름을 말할 수는 없더라도 Void 를 피하는 방법은 없습니다.

명확한 반환 타입을 설정하지 않은 모든 함수는 Void 를 반환하기 때문입니다.

func doSomething() { ... }

// 위의 함수에 숨은 내용은 다음과 같습니다

func doSomething() -> Void { ... }

이 행동은 특별히 유용하진 않지만 만약 Void 를 반환하는 함수의 결과를 변수에 할당하려고 시도한다면 컴파일러는 경고를 띄울 것입니다.

doSomething() // 경고 없음

let result = doSomething()
// ⚠️ Constant 'result' inferred to have type 'Void', which may be unexpected

이러한 경고는 Void 타입을 명시해주는 것으로 조용히 시킬 수 있습니다.

let result: Void = doSomething() // ()

{% info %} 반대로 Void 가 아닌 값을 반환하는 함수는 결괏값을 할당하지 않는다면 경고를 띄웁니다.

더 자세한 사항은 SE-0047 "Defaulting non-Void functions so they warn on unused results(Void가 아닌 함수에 경고를 띄워 결과가 사용되지 않는 것을 방지)"에서 확인하세요. {% endinfo %}

Void에서 Return 시도하기

Void? 를 띵해질 때까지 보다 보면 Bool 이라고 착각하는 실수를 하는 경우가 있습니다. Void?는 .some(()).none , Bool은 truefalse 의 두 가지 값을 가지기 때문에 동치(isometric)입니다.

하지만 동치는 똑같음을 의미하는 것은 아닙니다. 둘의 가장 두드러진 차이점은 BoolExpressibleByBooleanLiteral 인데 Void 는 그렇지 않고 그럴 수도 없다는 것입니다. 같은 이유로 Equatable 도 있습니다. 그러니 다음과 같은 행동은 할 수 없습니다.

(true as Void?) // error

하지만 Void?Bool 과 같은 방식으로 작동할 수 있습니다. 무작위로 에러를 내뱉는 다음과 같은 함수가 있습니다.

struct Failure: Error {}

func failsRandomly() throws {
    if Bool.random() {
        throw Failure()
    }
}

이 메소드를 사용하는 올바른 방법은 do / catch 블록에서 try 표현을 통해 호출하는 것입니다.

do {
    try failsRandomly()
    // 성공했을 때 실행할 내용
} catch {
    // 실패했을 때 실행할 내용
}

틀렸지만 표면적으론 유효한 방식은 failsRandomly()Void 를 반환한다는 사실을 활용하는 것입니다. try? 표현은 failsRandomly()Void? 를 반환할 경우에 옵셔널 값을 변형해줍니다. Void?.some 값(!= nil 인 값)을 가진다는 것은 아무 에러도 반환하지 않는다는 것을 의미합니다. 그리고 successnil 이라면 메소드가 에러를 발생시켰다고 알겠죠.

let success: Void? = try? failsRandomly()
if success != nil {
    // 성공했을 때 실행할 내용
} else {
    // 실패했을 때 실행할 내용
}

do / catch 블록의 모양새를 좋아하지 않으신다면 위의 방식의 모양이 마음에 드실 것입니다.

과장된 내용일 수도 있지만, 이 접근법은 특정하고 이상한 상황에서도 유효합니다. 예를 들자면 여러분이 클래스의 정적 프로퍼티를 사용해서 게으르게 어떠한 사이드 이펙트를 정확히 딱 한 번만 실행하도록 만들고 싶은 경우에도 사용할 수 있습니다.

static var oneTimeSideEffect: Void? = {
   return try? data.write(to: fileURL)
}()

하지만 여전히 Error 또는 Bool 값이 더 나을 것입니다.

밤에는 "Clang" 이 되는 것들

만약 이 으스스한 글을 읽다가 전율을 느끼셨다면 Void 타입의 파괴적인 에너지를 영혼을 따듯하게 해주는 많은 양의 열로 바꾸는 마법을 부릴 수 있습니다.

...무슨 말이나면 다음 코드가 lldb-rpc-server 를 사용해 CPU를 최대로 만들 수 있다는 뜻입니다.

extension Optional: ExpressibleByBooleanLiteral where Wrapped == Void {
    public typealias BooleanLiteralType = Bool

    public init(booleanLiteral value: Bool) {
        if value {
            self.init(())!
        } else {
            self.init(nilLiteral: ())!
        }
    }
}

let pseudoBool: Void? = true // 절대 찾을 수 없는 에러

러브크래프트적인 전통을 따르면, Void 는 컴퓨터가 처리할 수 없는 물리적인 형태를 가지고 있습니다. 간단하게 말하자면 그것을 보려고 한다면 프로세스가 미친듯이 렌더링될 것입니다.

무의미한 승리

지금까지 Void 에 대한 연구 결과를 우리에게 익숙한 말로 결론지어 보겠습니다.

enum Result<Value, Error> {
    case success(Value)
    case failure(Error)
}

Never 타입에 대한 글을 기억하시나요? 그렇다면 Error 타입에 Never 를 넣으면 Result 타입이 언제나 성공을 표현하게 만들 수 있다는 사실을 아실 것입니다.

비슷한 방식으로 우리는 VoidValue 타입으로 사용해서 연산자들이 성공하면 어떠한 의미 있는 결과도 발생시키지 않도록 만들 수 있습니다.

다음과 같이 주기적으로 서버에 핑을 보내는 간단한 네트워크 리퀘스트를 예시로 들 수 있습니다.

func ping(_ url: URL, completion: (Result<Void, Error>) -> Void) {
    // ...
}

{% info %} HTTP 의미론에 따르면 가상의 /ping 엔드포인트의 올바른 스테이터스 코드는 204 No Content라고 합니다. {% endinfo %}

그 리퀘스트의 컴플리션 핸들러의 성공 부분은 다음과 같이 작성될 것입니다.

completion(.success(()))

야단스러운 괄호가 마음에 들지 않는다면(왜죠?) Result 에 전략적인 익스텐션을 추가해서 좀 더 좋게 만들 수 있습니다.

extension Result where Value == Void {
    static var success: Result {
        return .success(())
    }
}

잃은 것은 없지만, 얻은 것도 없네요.

completion(.success)

오늘 내용이 순수한 학문적 내용이고 심지어 철학적이라고 생각하실 수도 있습니다. 하지만 Void 에 대해 알면 알수록 실제 Swift 프로그래밍 언어에 대한 깊은 인사이트가 생길 것입니다.

Swift가 빛을 보기 전인 고대엔 튜플은 언어에서 근본적인 역할을 해왔습니다. 그것들은 서로 다른 컨텍스트 사이를 유연하게 이동하며 인자 목록과 열거 값을 표현했습니다. 어떤 시점에 그 모델이 무너지기 시작했습니다. 그리고 Swift는 아직 이러한 이질적인 구조 사이의 불일치를 조정하지 않았습니다.

Swift 신화에 따르면 역할과 영향을 알 수 없는 무한의 중심에서 진정한 싱글톤인 Void 는 고대 신들의 귀감이었습니다. 컴파일러는 그것을 볼 수 없었습니다.

하지만 아마도 이것은 우리의 이해를 돕기 위한 발명품에 지나지 않을 것입니다. 언어의 장기적인 생존 가능성에 대한 우리의 불안함이 표현된 것이죠. 결국 여러분이 Void 를 쳐다보는 순간 Void 는 여러분을 뒤에서 쳐다볼 것입니다.