iOS/Swift

Swift) 클로저의 캡처와 참조 타입

Dev.Andy 2023. 8. 31. 23:57

머리말

포스팅을 하게 된 이유

클로저는 그저 이름이 없는 함수인가?

내가 기존에 사용하던 클로저는 그저 이름이 없는 함수, 함수의 이름을 짓기 귀찮을 때 사용하는 중괄호 `{}` 정도로만 여겼다. 하지만 수업 내용을 들으면서 단순히 클로가 함수의 기능을 넘어 값을 캡처하는 참조 타입이라는 것을 배웠는데 이를 정리하기 위해 포스팅을 하게 되었다.

클로저의 정의

공식 문서에 정의된 클로저는 다음과 같다.

Group code that executes together, without creating a named function.
이름이 있는 함수를 만들지 않은 채, 같이 실행되는 코드의 묶음

Closures | Documentation

클로저의 추가적인 기능, 캡처(capture)

공식 문서의 서문에서 정의를 읽다가 조금만 아래로 내려가 보면 클로저의 기능인 캡처(capture)에 대하여 다음과 같이 적혀 있다.

Closures can capture and store references to any constants and variables from the context in which they’re defined. This is known as closing over those constants and variables. Swift handles all of the memory management of capturing for you.
클로저는 그것을 정의한 문맥의 상수와 변수에 대한 참조(reference)를 캡처하고 저장한다. 이것은 그 상수와 변수를 에워싸는(closing over) 것으로 알려져 있다. 스위프트는 캡처에 대한 모든 메모리 관리를 다룬다.

클로저(closure)가 단순히 코드를 중괄호의 범위로 감싸는(close over) 것을 넘어서 그곳에서 다루는 상수/변수에 대한 참조를 관리한다는 것을 의미한다. 이에 대해 좀 더 자세히 알아 보자.

클로저의 캡처

이제 공식 문서의 서문을 넘어서 본격적으로 캡처에 대한 내용에 대해 알아 보자.

값을 캡처하기(Capturing Values)

... The closure can then refer to and modify the values of those constants and variables from within its body, even if the original scope that defined the constants and variables no longer exists.
클로저는 이후에 자신의 범위에 있는 상수와 변수의 값을 참조하고 변경할 수 있는데, 기존의 스코프에서 정의한 상수와 변수가 더 이상 존재하지 않더라도 (값을 참조하고 변경할 수 있다.)

Capturing Values - Closures | Documentation

클로저는 범위 안의 사용되는 상수나 변수는 호출 이후에도 메모리에서 사라지는 게 아니라 캡처(capture)에 의해 값이 참조되어 계속 존재한다! 아직 한 발 남았다...

코드 실습

백문이 불여일견! 실제 코드를 입력하여 이를 직접 살펴 보자.

정수(Int)를 입력 값으로 받고 innerIncrement 함수(Void를 입력 받고 Int를 반환하는 함수)를 반환하는 outerIncrement 함수가 있고, 그 안에 또 다시 Void를 입력 받고 Int를 반환하는 innerIncrement 함수가 있다

코드 - 중첩 함수(nested function)에서의 캡처

func outerIncrement(_ amount: Int) -> () -> Int {
    
    var total = 0
    
    func innnerIncrement() -> Int {
        total += amount
        return total
    }
    
    return innnerIncrement
}

innerIncrement 함수는 아무 입력을 받지 않고, 외부의 total와 외부의 매개변수 amount를 더하여 total를 반환한다.

outerIncrement 함수는 Int를 입력 받아서, innerIncrement 함수 자체를 반환한다.

결괏값

상수 result에 outerIncrement(10)을 넣으면 () → Int 형태의 함수를 반환하고, 이를 소괄호를 이용해 호출하면 innerIncrement 0에서 10을 더한 10이 반환된다.

let result = outerIncrement(10)

// result의 타입은 함수!
print(result) // (Function)
print(type(of: result)) // () -> Int

// 소괄호를 이용한 호출
result() // 10

함수(클로저)의 종류 이후에도 캡처에 의해 살아 있는 스코프 안의 상수와 변수

하지만 여기서 지속적으로 result를 호출해 보자... 좀비처럼 죽지 않은 채 10씩 더해지는 모습을 발견할 수 있다. 따라서 outerIncrement 안의 amount와 total은 캡처로 인하여 참조 되었기에 계속 활용할 수 있는 것이다.

result() // 20
result() // 30
result() // 40
result() // 50
result() // 60

다른 인자를 담은 함수를 다른 변수에 할당한다면?

아래처럼 새로운 변수(anotherResult)에 같은 함수이지만 인자 값을 다르게 준다면 새로운 참조 값을 캡처하기에 호출할 경우 새롭게 값이 호출된다. 여기서 기존의 변수(result)를 호출하면 기존의 참조 값을 불러온다.

let anotherResult = outerIncrement(8)
anotherResult() // 8
anotherResult() // 16
anotherResult() // 24
anotherResult() // 32
anotherResult() // 40

result() // 70

클로저는 참조 타입이다(Closures Are Reference Types)

캡처는 주솟값을 참조한다.

함수나 클로저 안의 지역 변수를 외부에서 직접 불러올 수는 없지만, 캡처를 이용해 해당 지역 변수를 참조하여 계속 활용할 수 있다. 눈치 챘을 수도 있는데 일부러 result와 anotherResult를 상수(let)를 두었는데도 클로저(함수)는 참조 타입이기에 호출할 때마다 값이 계속 증가하는 걸 알 수 있다.

let referredResult = result
referredResult() // 80
referredResult() // 90

anotherResult() // 48

클로저의 참조 기준은 클로저 자체가 아닌 그 안의 상수와 변수

위처럼 새로운 변수에 기존의 클로저를 담은 변수를 할당하더라도 result에 할당된 outerIncrement(10) 함수에 들어가는 상수/변수의 주솟값을 참조한다.

같은 이름의 함수 outerIncrement를 호출했다고 해서 다 같은 곳을 가리키는 것이 아니라, 함수/클로저 안의 상수/변수의 주솟값이 같은가를 기준으로 클로저의 캡처를 설정한다.

같은 인자를 담은 함수를 같은 변수에 새로 할당한다면?

이제 result를 변수로 바꾸고 함수를 할당하여 잘 호출하다가 같은 인자를 담은 같은 함수를 재할당해 보자. 

var result = outerIncrement(10)

// result의 타입은 함수!
print(result) // (Function)
print(type(of: result)) // () -> Int

// 소괄호를 이용한 호출
result() // 10
result() // 20
result() // 30
result() // 40
result() // 50
result() // 60

// 위와 같아 보이지만 새로운 주솟값이 할당되었다 -> 새로운 캡처
result = outerIncrement(10)
result() // 10
result() // 20

클로저는 참조 타입이기에 겉이 완전히 똑같은 result = outerIncrement(10)라 하더라도 새로운 주솟값이 할당 된다. 따라서 다시 새롭게 0부터 10씩 더해지는 걸 확인할 수 있다.