7.4.4 @escaping과 @autoescape

클로저를 함수나 메서드의 인자값으로 사용할 때에는 용도에 따라 @escaping과 @autoclosure 속성을 부여할 수 있습니다.

@escaping

@escaping 속성은 인자값으로 전달된 클로저를 전달해 두었다가 나중에 다른 곳에서도 실행할 수 있도록 허용해주는 속성입니다. 예제를 보겠습니다.

func callback(fn: () -> Void) {
    fn()
}

callback {
    print("Closure가 실행되었습니다")
}

// "Closure가 실행되었습니다"

정의된 함수 callback(fn:)은 매개변수를 통해 전달된 클로저를 함수 내부에서 실행하는 역할을 합니다. 이번에는 이 코드를 다음과 같이 바꿔 보겠습니다.

func callback(fn: () -> Void) {
    let f = fn // 클로저를 상수 f에 대입 
    f() // 대입된 클로저를 실행
}

이러면 let f = fn 이 구문에서 오류가 발생합니다. 오류의 내용은 "Non-escaping 파라미터인 fn은 오직 직접 호출하는 것만 가능하다"는 의미입니다. 클로저를 변수에 대입할 수 없고 바로 호출만 할 수 있다니.... 이를 이해하기 위해서는 인자값으로 전달되는 클로저의 특성을 알아야 합니다.

스위프트에서 함수의 인자값으로 전달된 클로저는 기본적으로 탈출불가(non-escape)의 성격을 가집니다. 이는 해당 클로저를 1. 함수 내에서 2. 직접 실행을 위해서만 사용해야 하는 것을 의미하며, 함수 내부라 해도 변수나 상수에 대입할 수 없습니다. 변수나 상수에 대입이 허용되면 내부 함수를 통한 캡처 기능을 활용해 클로저가 함수 바깥으로 탈출할 수 있기 때문입니다. 탈출이란 함수 내부 범위를 벗어나서 실행되는 것을 의미합니다.

동일한 의미에서 인자값으로 전달된 클로저는 중첩된 내부 함수에서 사용할 수도 없습니다. 내부 함수에서 사용할 수 있도록 허용할 경우, 이 역시 컨텍스트 캡처를 통해 탈출될 수 있기 때문입니다.

하지만 코드를 작성하다 보면 클로저를 변수나 상수에 대입하거나 중첩 함수 내부에서 사용해야 할 경우도 있는데요, 이때 사용되는 것이 @escaping 속성입니다. 앞서 작성했던 callback(fn:) 함수 매개변수 타입에 @escaping 속성을 추가해 봅시다.

func callback(fn: @escaping () -> Void) {
    let f = fn // 클로저를 상수 f에 대입
    f() // 대입된 클로저를 실행
}

callback {
    print("Closure가 실행되었습니다")
}

// "Closure가 실행되었습니다"

이제 입력된 클로저는 변수나 상수에 정상적으로 할당될 뿐만 아니라, 중첩된 내부 함수에 사용할 수 있으며, 함수 바깥으로 전달할 수도 있습니다. 말 그대로 탈출 가능한 클로저가 된 것입니다. 그런데 인자값으로 전달되는 클로저의 기본 속성이 탈출불가하도록 설정된 이유는 무엇일까요?

클로저의 기본 속성을 탈출불가(non-escape)하게 관리함으로써 얻어지는 가장 큰 이점은 컴파일러가 코드를 최적화하는 과정에서의 성능 향상입니다. 해당 클로저가 탈출할 수 없다는 것은 컴파일러가 더 이상 메모리 관리상의 일들에 관여할 필요가 없다는 뜻이기 때문입니다.

또한 탈출불가 클로저 내에서는 self 키워드를 사용할 수 있습니다. 왜냐하면 이 클로저는 해당 함수가 끝나서 리턴되기 전에 호출될 것이 명확하기 때문입니다. 따라서 클로저 내에서 self에 대한 약한 참조(weak reference)를 사용해야할 필요가 없습니다.

@autoclosure

@autoclosure 속성은 인자값으로 전달된 일반 구문이나 함수 등을 클로저로 래핑(Wrapping)하는 역할을 합니다. 쉽게 말해 이 속성이 붙어 있다면 일반 구문을 인자값으로 넣더라도 컴파일러가 알아서 클로저로 만들어 사용한다는 것입니다. 다음의 예제를 봅시다.

// 함수 정의
func condition(stmt: () -> Bool) {
    if stmt() == true {
        print("결과가 참입니다")
    } else {
        print("결과가 거짓입니다")
    }
}

함수 condition(stmt:)는 참/거짓을 반환하는 클로저를 인자값으로 전달받고 그 결과값을 문장으로 출력해 줍니다. 현재까지는 이 함수를 실행하려면 다음의 두 가지 방법을 사용했습니다.

// 1. 일반 구문
condition(stmt: {
    4 > 2
})

// 2. 클로저 구문
condition {
    4 > 2
}

다시 @autoclosure 이야기로 돌아가 봅시다. 작성된 2가지의 실행 방법에서 전달하고 싶은 것은 '4 > 2' 구문입니다. 하지만 일반 구문이나 클로저 구문 둘 다 {} 형태로 감싸 클로저 형태로 만든 후 인자값으로 전달해야 합니다. 인자값의 입력 타입이 반드시 클로저여야 하기 때문입니다. 하지만 @autoclosure 속성을 붙이면 이같은 제약이 사라지고 구문만 인자값으로 전달해줄 수 있습니다.

func condition(stmt: @autoclosure () -> Bool) {
    if stmt() == true {
        print("결과가 참입니다")
    } else {
        print("결과가 거짓입니다")
    }
}

이제 @autoclosure 속성의 영향으로 더이상 일반 클로저를 인자값으로 사용할 수 없습니다. 또한 트레일링 클로저 구문도 @autoclosure 속성이 붙고 나면 더이상 사용할 수 없습니다.

// 실행 방법
condition(stmt: (4 > 2))

클로저가 아니라 그 안에 들어가는 내용만 인자값으로 넣어줄 뿐입니다. 전달된 인자값은 컴파일러가 자동으로 클로저 형태로 감싸 처리해줍니다.

@autoclosure 속성과 관련하여 알아두어야 할 개념이 하나 있습니다. 바로 '지연된 실행'입니다.

// 빈 배열 정의
var arrs = [String]()

func addVars(fn: @autoclosure () -> Void) {
    // 배열 요소를 3개까지 추가하여 초기화
    arrs = Array(repeating: "", count: 3)
    // 인자값으로 전달된 클로저 실행
    fn()
}

// 구문 1 : 아래 구문은 오류가 발생한다
arr.insert("KR", at: 1)

// 구문 2 : 아래 구문은 오류가 발생하지 않는다
addVars(fn: arrs.insert("KR", at: 1)) 

구문 1에서 빈 배열 arrs는 초기화만 되어 있고 내용은 모두 비어 있는 상태로 addVars(fn:) 함수 내부에서는 이 배열의 사이즈를 3으로 확장하고 빈 값들로 초기화합니다. 즉 addVars(fn:) 함수가 실행되기 전까지 이 함수의 인덱스는 0까지밖에 없습니다. 이 때문에 두번째 인덱스 위치에 insert를 할 수 없는 것입니다.

구문 2에서는 오류가 발생하지 않습니다. 바로 지연된 실행 때문입니다. 함수 내에 작성된 구문은 함수가 실행되기 전까지는 실행되지 않습니다. @autoclosure 속성이 부여된 인자값은 보기엔 일반 구문 형태이지만 컴파일러에 의해 클로저, 즉 함수로 감싸지기 때문에 위와 같이 작성해도 addVars(fn:) 함수 실행 전까지는 실행되지 않으며, 해당 구문이 실행될 때에는 이미 배열의 인덱스가 확장된 후이므로 오류가 발생하지 않습니다.

Last updated