[TIL] 클로저

Valse·2022년 7월 6일
0

Swift

목록 보기
1/8
post-thumbnail

1. 클로저?

클로저(Closures)는 전달과 수행을 자체적으로 할 수 있는 함수 블록입니다.
Swift의 클로저는 C와 Objective-C의 블럭과 다른 프로그래밍 언어의 람다와 유사합니다.
- 공식 가이드

클로저를 정리하기에 앞서서, 왜 클로저를 쓰는지를 생각해보자.
함수를 일급객체로 받을 수 있다면 변수에 할당이 가능하고, 함수의 인풋과 아웃풋으로도 활용할 수 있다.
클로저는 함수를 일급객체로 활용하기 위한 방안이다.
함수를 이리저리 전달하기 위해 함수 자신의 이름을 지운 것이 클로저이다.
그래서 파라미터로 전달되는 시점에 함수가 실행해야 할 행위가 정의된다.

파라미터로 전달할 때에는 파라미터 이름을 써주고, 타입을 써주면 된다.
예시에서는 인풋과 아웃풋이 없는 타입의 클로저로 파라미터를 받을 수 있도록 했다.
그렇기 때문에 실행할 때마다 내가 원하는 클로저를 사후적으로 정의할 수 있다.

클로저의 기능은 함수이기 때문에, 스택 프레임을 만든다는 사실도 잊어선 안 된다.
이 사실을 잘 이해해야 @escaping캡쳐 현상을 똑바로 이해할 수 있다.

// 변수 할당
var a = {
	print("이것봐라?")
}

// 함수의 인풋으로 closure 타입을 명시하고 
func doClosure(closure: () -> Void) {
	print("무엇을 실행?")
	closure()
}

// 이렇게 함수를 호출하는 시점에 클로저를 정의하며 실행
doClosure(closure: {
	print("이것을 실행")
}) // 출력: 무엇을 실행? -> 이것을 실행

// 이런 것도 가능하다.
func closureCaseFunc(a: Int, b: Int, askPrint: (Int) -> Void) {
    let c = a + b
    askPrint(c)
}

closureCaseFunc(a: 8, b: 8, askPrint: { c in
    print("지금 먹고 있는 게 뭔지 \(c)번째 물어봅니다")
}) // 지금 먹고 있는 게 뭔지 16번째 물어봅니다.

closureCaseFunc(a: 8, b: 8, askPrint: { c in
    print("지금 그걸 \(c)번째 먹고있다구요?")
}) // 지금 그걸 16번 째 먹고있다구요?

closureCaseFunc(a: 8, b: 8, askPrint: { c in
    print("\(c)번 물어봤으니 그만 하겠습니다.")
}) // 16번 물어봤으니 그만 하겠습니다.

func multiClosure(closure: () -> (), closure2: () -> ()) {
    print("1")
    closure1()
    closure2()
}

multiClosure(closure1: {
    print("2")
}, closure2: {
    print("3")
}) // 1, 2, 3

클로저의 목적과 의의는 사용의 편리함에 있다.
그래서 코드를 작성하는 데에도 많은 편의 기능이 내장되어 있다.
클로저가 함수의 마지막 파라미터에 위치할 때 이를 후행 클로저(trailing closure)로 작성할 수 있고
파라미터의 간소화와 아규먼트 레이블의 축약도 가능하다.
이를 통한 간소화 과정은 아래와 같다.

func closureUsage(a: Int, b: Int, closure: (Int) -> Void) {
    let c = a + b
    closure(c)
}

// closure(c) 를 따로 쓰지 않고 후행 클로저의 형태로 곧바로 정의하여 실행
// single-line의 리턴값을 반환하는 클로저는 return 키워드도 생략할 수 있다.
closureUsage(a: 3, b: 4) { c in
	print(c) // 7
}

func paramShortening(param: (String) -> Int) {
	print(param("Swift"))
}

paramShortening(param: { str in
	str.count
}) // 5


// 아규먼트 레이블을 축약하고 완전 간소화
paramShortening(param: {
	$0.count // $0 == 1번 파라미터, $n번 파라미터
} // 5

paramShortening { $0.count }

2. 함수와 차이점

  1. 클로저 함수 블록에서는 아웃풋의 타입이 어떤지 써주지 않아도 된다.
  2. 만약 클로저의 파라미터 인풋 타입이 변수를 할당하는 시점에 명시되어 있다면
    파라미터의 인풋 타입은 생략할 수 있다.
  3. 강력한 타입추론을 지원한다.
let ex1 = { (str: String) in "Hello, \(str)" }
let ex2: (String) -> String = { str in "Hello, \(str)" }
let ex3 = {
    print("no input, no output")
}
let ex4 = { param in param + "!" }

3. ✨클로저의 메모리와 캡쳐 현상

클로저는 참조 타입이다.
호출될 때 메모리 주소를 전달하고 그 값을 Heap, 주소를 Stack에 저장한다.
Heap에서 동적 할당이 일어나기 때문에 개발자는 RC를 통해 클로저를 관리해야 한다.

클로저에서 수행한 특정 결과값을 Heap에 저장한다면, 이 값은 도대체 어떻게 되는 걸까?

var outScope = 0
var closureCapture = {
    outScope += $0
}

closureCapture(10)
print(outScope) // 10
closureCapture(10)
print(outScope) // 20 ❓

뭔가 이상하다. closureCaputure 클로저는 현재 outScope에 10을 더하고 있다.
0으로 초기화 되어 있던 outScope 변수는 한 번 클로저의 실행으로 10이라는 값을 받았다.
그런데 재차 클로저를 실행하자 값이 20이 되었다.

함수가 자기 내부에서 변수와 상수를 선언하고 활용하면 스택 프레임 내부에서 처리하고
스택 프레임이 종료되면 모두 초기화 된다.

그러나 예시의 클로저는 자기 내부의 변수가 아니라 main()에서 선언된 outScope를 활용하고 있다.
따라서 예시의 클로저는 자신의 스택 프레임에 outScope 변수를 갖고 있지 않다.
이 변수는 어떻게 저장되는가?

예시의 outScope 변수는 클로저가 할당된 Heap의 영역에 메모리 주소 형태로 저장된다.
그 값이 클로저에 의해 지속적으로 변형되기 때문이다.
이것이 캡쳐 현상이다.

만약 이 변수가 클로저에 의해 변화하지 않았다면 캡쳐가 일어나지 않고 값의 복사본을 저장한다.
그러나 클로저에 의해 값이 변경되는 순간 캡쳐가 발생한다.

즉, 클로저에 의해 값이 변경된다면 값 타입으로 복사하여 저장하는 것이 아니라
외부 스택 프레임에 있는 변수의 주소를 참조하여 클로저 Heap 영역에 저장하게 된다.

As an optimization, Swift may instead capture and store a copy of a value if that value isn’t mutated by a closure, and if the value isn’t mutated after the closure is created.
최적화의 일환으로, Swift는 클로저에 의해 변수 값이 변경되지 않거나, 클로저가 생성된 이후에 값이 변경되지 않으면 캡쳐를 대신하여 값의 복사본을 저장할 수 있다.
- 공식 가이드

그래서 캡쳐는 클로저가 자기 자신의 변수, 상수가 아닌 외부의 변수, 상수를 자기 자신의 실행에 참조하고 변경할 때 발생한다.
위 예시에서 사용된 클로저는 자기 자신의 스택프레임을 만들고, 자신이 갖고 있던 outScope의 주소 값을 통해 그 변수의 실제 값을 스택에서 찾는다.
이러한 이동은 얼핏 봐도 비효율적이다. 추후 메모리 관리 때 더 언급하겠지만, 캡쳐리스트의 필요성이 두각되는 지점이다.


4. 클로저를 어떻게 쓸 수 있을까?

! 주관적 견해가 다량 포함되어 있습니다.

그렇다면 클로저를 어디에 쓸 수 있을까?
고차함수의 파라미터러 전달하기도 하고 뷰컨을 관리하기 위해서도 곧잘 활용된다.
무엇보다 클로저와 고차함수는 사용법을 모두가 알고 있다.
모두가 공유하는 사용법을 잘 활용하는 요령은 함수형 프로그래밍을 실천하기 위해 꼭 익혀야 한다.
함수형 프로그래밍의 의의는 하나의 행위를 사용법 없이 실행할 수 있는 함수를 정의하는 데에 있기 때문이다.
어떤 변수를 인풋으로 어떻게 받고 반복문을 어떻게 작성해서 돌리고 가공해야 하는지 익히는 것은... 사람마다 방식이 다르다.
내가 편한 방식과 당신이 편한 방식이 다르다는 건 협업 효율성을 굉장히 저하시킨다.
함수형 프로그래밍이 지향하는 가치와 상반된다.
왜냐하면 함수형 프로그래밍의 최종 지향점은 에러와 예외 사항을 만들지 않는 것에 있기 때문이다.

그냥 어떤팀이원하는값을리턴해주는함수()를 정의하는 게 훨씬 편하고 실행도 쉽다.
소괄호를 열고 닫는 행위만으로 최고의 효율성을 낼 수 있다.

// 짝수만 필터링해서 새로운 배열로 반환하는 고차함수 filter
var result = [1,2,3,4,5,6,7,8,9,0].filter { $0 % 2 == 0 }
print(result) // [2, 4, 6, 8, 0]

// result 배열의 요소와 숫자 요소의 차이값과 해당 연산이 일어난 짝수를 튜플로 만들어 배열을 만든다.
// 그리고 그 중에서 차이값이 -1과 1 사이에 있는 요소만을 배열로 리턴한다.
var numbers = [1,2,3,4,5]
for i in numbers {
	var tmp = result.map { ($0, $0 - i) }.filter { -1 ... 1 ~= $0.1 })
}

5. @escaping

일반적인 클로저는 함수가 끝나면 더 이상 존재하지 않는다.
그러나 이 클로저를 함수가 끝나도 보관하고 싶을 경우가 있다.
일급객체로서 클로저를 활용하고자 할 때, @escaping 키워드 활용을 고려해보자.

사족? : 원래 클로저 타입 파라미터는 escaping이 기본 설정이었다고 한다.
inout 파라미터로 클로저를 종료할 수 없게 되자 noescape 선호가 늘었고, 그에 따라 noescape 가 기본 설정이 된 것 같다. 이 흐름에 따라 @escaping이 등장한 것일까?
참고

이 키워드를 활용해서 함수의 실행흐름과 그 스택 프레임을 벗어날 수 있도록 할 수 있다!

함수 내부의 클로저를 외부 스코프 변수에 저장하거나 비동기 코드(GCD)를 사용해야 할 때 사용할 수 있다.
첫 번째의 경우, 클로저의 주소가 담긴 변수를 main()에 정의하고 클로저는 힙에 저장된다.
두 번째의 경우, 쓰레드의 이동으로 클로저를 실행할 또 다른 스택(쓰레드)로 옮길 수 있게 된다.
쓰레드 간의 참조는 불가능하지만, 각 쓰레드들은 데이터, 힙 영역을 공통으로 참조할 수 있다는 사실을 잊지 말자.

func toSecondThread(closure: @escaping (String) -> Void) {
    var name = "Hong"

    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        closure(name)
    }
}

오늘은 클로저를 정리해보았다.
앞으로 메모리 관리와 비동기 등등을 정리해야 하는데 그에 앞서서 꼭 정리가 필요했다.
뿌듯
220706

profile
🦶🏻🦉(발새 아님)

0개의 댓글