Closures 다시보기-1

고라니·2023년 11월 10일
0

TIL

목록 보기
46/67

분명 잘 알고 있다고 생각했는데 escaping, 캡쳐 등 헷갈리는 부분이 아직 많이 있어서 clousure에 대해 다시 알아봐야 할 필요성이 느껴졌다.
차근차근 알아보겠다.

Closure

클로저는 코드에서 전달하고 사용할 수 있는 독립된 기능 블록이다. 함수와 유사한 기능을 가지며, 주로 코드 블록을 변수나 함수의 매개변수로 전달하거나 반환하는데 사용된다.
클로저는 주변 범위의 변수와 상수를 캡처하고 저장할 수 있어서 유용하게 사용된다.

사실 우리가 흔히 사용하는 일반 함수는 이름이 있는 클로저로, 결국 클로저에 속한다고 볼 수 있다. 하지만 보통 클로저를 지칭할 때는 일반적인 함수보다는 이름이 없이 사용되는 경우를 말하는듯 하다. 아래 항목 모두 클로저라고 할 수 있다.

  • 전역 함수: 이름이 있고 값을 캡처하지 않는 클로저
  • 중첩 함수: 이름이 있고 함수의 값을 캡처할 수 있는 클로저
  • 우리가 흔히 지칭하는 클로저: 값을 캡처할 수 있는 경량 구문으로 작성된 이름없는 클로저

문서에서 친절하게 캡쳐 개념에 익숙하지 않아도 다 알려줄거라니까 걱정하지 말라고 한다. Good

Clouser Expression Syntax

클로저의 기본 구조

{ (매개변수) -> 반환 타입 in
	// 클로저 실행 코드
}
  • 매개변수: 클로저가 사용할 매개변수, 기본 값을 가질 수는 없음
  • 반환타입: 클로저가 반환하는 값 타입
  • in: 클로저의 매배견수와 반환 타입 정의와 실행 코드 분리
  • 실행 코드: 클로저가 수행할 실제 코드 블록

타입 생략

reversedNames = names.sorted(by: { s1, s2 in return s1 > s2 } )

클로저가 함수나 메서드 인수로 사용되는 경우 매개변수 타입과 반환 타입이 추론 가능하다. 그렇기 때문에 매개변수 타입과, 반환 타입은 생략 가능하다.

return 생략

reversedNames = names.sorted(by: { s1, s2 in s1 > s2 } )

일반 함수처럼 클로저가 단일 표현식을 갖고 있다면 return도 생략 가능하다.

매개변수 생략

reversedNames = names.sorted(by: { $0 > $1 } )

스위프트는 인라인 클로저의 매개변수 이름을 약식으로 제공한다. ($0, $1, $2)
이 약식을 사용한다면 매개변수 이름도 생략 가능하다.

더 간결한 표현

reversedNames = names.sorted(by: >)

'>' 같은 연산자는 두개의 인자를 비교하고 Bool을 반환한다는 시그니처를 가지고 있기 때문에 연산자만 전달하는것도 가능하다.

후행 클로저(Trailing Closures)

func someFunctionThatTakesAClosure(closure: () -> Void) {
    // 함수 바디
}

// 이렇게 함수를 호출하면 후행 클로저를 사용하지 않는 경우입니다:
someFunctionThatTakesAClosure(closure: {
    // 클로저 바디
})

// 이렇게 함수를 호출하면 후행 클로저를 사용하는 경우입니다:
someFunctionThatTakesAClosure() {
    // 후행 클로저 바디
}

후행 클로저를 사용하면 함수 호출시 클로저를 함수의 괄호 뒤에 작성할 수 있고 똑같이 함수의 매개변수로 처리 된다.
후행 클로저는 클로저가 함수의 마지막 매개변수 위치에 있는 경우 사용 가능하다.

someFunctionThatTakesAClosure {
    // 후행 클로저의 본문
}

후행클로저만 유일하게 함수의 매개변수로 제공하고 있다면 괄호'()' 도 생략 가능하다.

함수가 여러개의 클로저를 받는 경우

func loadPicture(from server: Server, completion: (Picture) -> Void, onFailure: () -> Void) {
    if let picture = download("photo.jpg", from: server) {
        completion(picture)
    } else {
        onFailure()
    }
}

이 함수처럼 일반 매개변수, 클로저, 클로저 순으로 매개변수를 입력받는 경우
첫번째 클로저에 해당하는 매개변수 이름은 생략하고, 나머지 클로저는 후행 클로저로 표현하되 인자 레에블을 붙이면 된다. (아래의 코드처럼)

loadPicture(from: someServer) { picture in
    someView.currentPicture = picture
} onFailure: {
    print("Couldn't download the next picture.")
}

첫번째 클로저 매개변수 이름인 'completion'은 생략했고 두번째 클로저 매개변수 이름인 onFaulure은 생략하지 않아서 두 클로저를 구분하고 있다.

위의 코드는 네트워크 작업을 백그라운드로 디스패치 하여 네트워크 작업이 완료되면 두 개의 완료 핸들러 중 하나를 호출하게 한다. 이렇게 작성하면 네트워크 실패처리 코드와 성공처리 코드를 깔끔하게 분리 가능하다.

Capture Values

캡처는 주변 컨텍스트의 범위가 더 이상 존재하지 않더라도 클로저가 해당 값을 보존할 수 있는 기능을 말한다.

가장 간단한 형태의 캡처 상황은 중첩 함수에서 볼 수 있다.
중첩 함수의 매개변수와 함수내에서 정의한 변수를 내부 함수가 캡처하는 것이다. 아래의 예시를 보면서 알아보자

func makeIncrementer(forIncrement amount: Int) -> () -> Int {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

조금 복잡하니. 차근차근 분석해보자

  • makeIncrementer의 반환 타입은 () -> Int, 즉 함수를 반환한다.
  • 반환되는 함수는 incrementer 함수로, 매개변수가 없으며, Int를 반환한다.
  • 외부 함수인 makeIncrementer은 매개변수로 amount와, 내부에서 정의한 runningTotal을 가지고 있다.
  • 내부 함수인 incrementer은 인자를 가지고 있지 않지만 runningTotal과 amount를 참조한다.

위와 같은 상황에서 변수에 makeIncrementer를 호출해서 incrementer을 할당하면 makeIncrementer은 작업이 완료되면 메모리에서 해제되는데 amount와 runningTotal을 참조하는 incrementer가 제대로 작동할 수 있을까?
위의 상황에서 바로 클로저는 캡처리스트를 통해 incrementer가 runningTotal과 amount를 캡처하고 자신의 본문에서 사용할 수 있다. 캡처를 하면 makeIncrementer의 호출이 끝난 후에도 해당 값이 사라지지 않고 incrementer 함수가 호출될 때마다 사용 가능하다는 것을 보장한다.

makeIncrementer의 실제 사용 예시를 보자

let incrementByTen = makeIncrementer(forIncrement: 10)

incrementByTen()
// returns a value of 10
incrementByTen()
// returns a value of 20
incrementByTen()
// returns a value of 30
  • incrementByTen 이라는 상수는 runningTotal에 10을 더하는 incrementer함수를 참조한다.

위의 설명대로 runningTotal과 amount가 캡처되어 사라지지 않고 incrementer 함수가 호출될 때마다 사용 가능한 것을 볼 수 있다.

새로 makeIncrementer을 통해 새로운 incrementer을 생성하면 별도의 캡처 값을 갖게 되는 것을 볼 수 있다.

let incrementBySeven = makeIncrementer(forIncrement: 7)
incrementBySeven()
// returns a value of 7

새로운 incrementer을 호출하더라도 기존 incrementer의 캡처값에는 영향을 미치지 않는다.

incrementByTen()
// returns a value of 40

이러한 방식은 강한 참조 순환이 발생할 수 있다.
에를 들면 클래스 인스턴스의 변수나 상수를 캡처하게 되면 해당 인스턴스의 참조카운트가 즐가하여 메모리 누수로 이어질 수 있다.Swift는 이런 강한 순환 참조를 방지하기 위해 캡처 리스트를 사용하 어떤 참조를 강하게, 혹은 약하게 참조해야 하는지 지정할 수 있어다.
즉 캡처 리스트를 통해 강한 순환 참조를 예방하고 메모리 누수를 방지할 수 있다.
아래는 간단한 캡처리스트 사용 예시이다.

{ [weak self] in
    // self를 약한 참조로 캡처
    self?.someMethod()
}

'[]'(캡처 리스트)에 weak self로 지정하여 약한 참조로 캡처한다. 해당 클로저가 캡처하고 있는 클래스의 인스턴스가 nil이 되면 캡처 참조도 같이 메모리에서 해제된다.

Closures Are Reference Types

클로저는 함수와 마찬가지로 참조 타입(Reference Types)이다. 그렇기
때문에 incrementBySeven과 incrementByTen가 상수이지만 캡처한 runningTotal 변수를 증가시킬 수 있는것이다.

let alsoIncrementByTen = incrementByTen
alsoIncrementByTen()
// returns a value of 50


incrementByTen()
// returns a value of 60

상수나 변수에 클로저나 함수를 할당할 때, 실제로 해당 함수나 클로저를 참조하게 되는것이다.

조금 헷갈려서 추가 설명을 남겨본다. 상수나 변수에 클로저를 할당하면 함수나 클로저가 메모리에 할당되고 그 메모리 주소를 할당받은 상수나 변수가 가지게 되는 것. 클래스를 생각하면 이해가 쉽다. 그래서 incrementBySeven과 incrementByTen는 각각의 다른 incrementer을 참조하지만 반복 호출 시 각각의 참조된 incrementer의 runningTotal을 증가시키는 것이다.

Escaping Closures

Escaping Closures란 클로저가 함수의 매개변수로 전달되어 함수 내에서 정의되었지만 함수가 반환된 후에도 호출될 수 있는 상황을 의미한다.

위의 설명처럼 클로저가 함수 내에서 escaping(이탈)하여 외부에서도 호출될 수 있기 위해서는 클로저 파라미터 타입 앞에 '@escaping'을 작성하여 나타낼 수 있다.

예시로 비동기 작업을 수행하는 많은 함수는 completionHandler를 사용하여 클로저를 인자로 받아 작업이 끝난 후 수행할 작업들을 정의 하도록 한다.

var completionHandlers: [() -> Void] = []
func someFunctionWithEscapingClosure(completionHandler: @escaping () -> Void) {
    completionHandlers.append(completionHandler)
}

위의 코드에서 someFunctionWhitEscapingClosure(_:) 함수는 클로저를 매개변수로 받아서 외부에 선언된 배열에 추가하고 있다.
이 때 @escaping을 작성하지 않으면 컴파일 오류가 발생한다.

self를 참조하는 escaping 클로저는 클래스의 인스턴스를 가리키는 경우 주의해야 한다. 강한 순환 참조를 발생시킬 수 있음(위에서 설명했던 캡처로 인한 강한 순환 참조 발생, self가 참조되면 참조카운트가 1이상으로 유지되어 메모리에 계속 남아있게 된다. 그렇기 때문에 self를 참조할때는 weak나 unowned 권장)

클로저에서 self를 캡처할 때, 명시적으로 self를 사용하거나 캡처 리스트를 사용하는 것이 self 참조를 제어하는데 도움이된다.
self를 명시적으로 표현하면 코드를 볼때 self를 참조하고 있다는것을 잘 알 수 있음

아래의 코드를 보면 someFunctionWithEscapingClosure는 명시적으로 self를 참조하고 있다, 반대로 someFunctionWithNonescapingClosure는 암시적으로 self를 참조한다.

func someFunctionWithNonescapingClosure(closure: () -> Void) {
    closure()
}


class SomeClass {
    var x = 10
    func doSomething() {
        someFunctionWithEscapingClosure { self.x = 100 }
        someFunctionWithNonescapingClosure { x = 200 }
    }
}


let instance = SomeClass()
instance.doSomething()
print(instance.x)
// Prints "200"


completionHandlers.first?()
print(instance.x)
// Prints "100"

다음의 코드는 캡처 리스트에 self를 포함시켜 암시적으로 self를 참조하고 있다.

class SomeOtherClass {
    var x = 10
    func doSomething() {
        someFunctionWithEscapingClosure { [self] in x = 100 }
        someFunctionWithNonescapingClosure { x = 200 }
    }
}

만약 self가 값타입인 구조체나 열거형이라면 self를 명시적으로 작성하지 않아도 self를 함시적으로 참조할 수 있다.
하지만, 이 경우 escaping 클로저는 self의 값을 변경할 수 없다.

struct SomeStruct {
    var x = 10
    mutating func doSomething() {
        someFunctionWithNonescapingClosure { x = 200 }  // Ok
        someFunctionWithEscapingClosure { x = 100 }     // Error
    }
}

위의 코드에서 escaping 클로저에서 self를 참조하면 에러가 발생하고, 이러한 에러는 값 타입의 COW(Copy On Write)메커니즘 방식의 오류를 방지한다.

escaping 클로저 상당히 어렵네 😫
정리하자면 함수의 매개변수로 전달된 클로저를 외부 상수나 변수로 탈출시켜서 함수가 반환된 이후에도 사용할 수 있도록 클로저 타입의 매개변수 앞에 @escaping을 작성해 주어야 하는것이고. 주로 비동기 작업에서 작업 이 끝나고 수행할 작업들을 정의하기 위해 사용한다. 그리고 self를 캡처하는 escaping 클로저는 강한 순환 참조를 주의해야 하며, self가 만약 값타입인 구조체나 열거형이라면 스트럭트로 정의한 값을 외부에서 함수를 통해 변경하려고 했을때 오류가 발생하는 것처럼 escaping 클로저도 self를 변경할 수 없다. someFunctionWithNonescapingClosure은 mutating 키워드가 작성된doSomething함수가 반환되기 전에 완료되는게 보장이 되어 있지만, someFunctionWithEscapingClosure즉 escaping클로저는 다른 위치, 다른 시점에 호출될 가능성이 있기 때문에 Swift는 이런 문제를 방지하기 위해 escaping 클로저는 값타입 내에서 self를 변경하지 못하게 하고 있는 것이다.

하다보니 길어져서 오토클로저는 다음에 바로 이어서 작성하겠다.

profile
🍎 무럭무럭

0개의 댓글