대망의 Closure다. 처음 공부할 때 정말 어려웠던 경험이 있다. 그 당시 생각을 하면서 찬찬히 적었으니 잘 이해가 되었으면 좋겠다. ARC의 개념은 후반 포스팅에 작성하였는데, 해당 단어가 궁금하다면 먼저 읽고 와도 상관없다. closure에서 다루는 대부분의 개념을 하나의 포스팅에서 다루고 싶어 불가피하게 흐름을 변경했다. 그럼 즐겁게 읽어주시길 바란다!

Closure 개요

  • 함수는 클로저에 포함된다.
    • Global function
      • 이름이 있음
      • value를 capture하지 않는 closure
    • Nested function
      • 이름이 있음
      • 해당 함수를 포함하는 function의 value를 capture하는 closure
    • closure
      • 이름이 없음
      • 둘러썬 context의 value를 capture
  • Capture란?
    • closure or nested function 코드 밖에서 사용되던 variable이나 constant의 값이 closure안에서 사용될 수 있는 것
    • 기본적으로 Reference Capture이다.
      • 즉, closure안에서 바깥 변수들을 참조하고 있다.
    • 하지만 해당 closure 내부에서 바깥 변수를 복사해서 가지고 싶을 수 있다.
    • 이런 경우 capture list를 사용한다.
      • 단 Reference type은 복사되지 않는다.
  • Closure(function)은 Reference type이다.

Clousure 표현 방법

  • 후행 클로저
  • 반환 타입 생략
  • 단축 인자 이름
  • 암시적 반환 표현

이 네가지를 배워볼 것이다. 말이 어려울 뿐 코드로 이해하자.

// 클로저를 매개변수로 갖는 함수
func calculate(a: Int, b: Int, method: (Int, Int) -> Int) -> Int {
    return method(a, b)
}
var result: Int // calculate의 값을 받을 변수

후행 클로저

// 후행 클로저
// 클로저가 함수의 마지막 전달인자일 때,
// 마지막 매개변수의 이름을 생략하고 함수의 소괄호 외부에 클로저를 구현할 수 있다.
result = calculate(a: 10, b: 10, method: { (left: Int, right: Int) -> Int in
    return left + right
})
print(result) // 20
result = calculate(a: 10, b : 10) { (left: Int, right: Int) -> Int in
    return left + right
}
print(result) // 20

위에 적혀있는 것이 기본적으로 알고 있는 클로저의 사용방법이다. 그런데 클로저가 해당 함수의 마지막 위치에 있을 경우, 조금 변용된 방법으로 사용할 수가 있는데, 소괄호 외부에 중괄호로 연 뒤, 안에 클로저를 구현해서 같은 결과를 얻을 수 있다.

반환 타입 생략

// 반환타입 생략
// calculate라는 함수는 이미 closure 함수가 Int형을 반환한다는 것을 알고 있다.
// 그렇기 때문에 이를 생략할 수 있다. 다만 in 키워드는 생략 불가
result = calculate(a: 10, b: 10, method: {(left: Int, right: Int) -> Int in
    return left + right
})
print(result) // 20

// 후행 클로저에도 적용가능
result = calculate(a: 10, b: 10) { (left: Int, right: Int) in
    return left + right
}
print(result) // 20
  • 클로저를 파라미터로 받는 함수의 경우, 이미 어떤 형식의 클로저가 들어올지 명시를 해두었기 때문에 가능한 방법이다.
  • 해당 함수를 콜할 때, 클로저의 반환 타입을 생략하여도 제대로 함수를 적었다면 동작한다.

단축 인자 이름

// 단축 인자이름
// 굳이 closure에 들어오는 매개변수이름이 필요없다면 명시하지 않고 $를 사용해 접근할 수 있다.
// 이 때 in 키워드는 사용할 필요가 없다.
result = calculate(a: 10, b: 10, method: {
    return $0 + $1
})
print(result) // 20

// 후행 클로저와도 가능!
result = calculate(a: 10, b: 10) {
    return $0 + $1
}
print(result) // 20
  • 클로저에서 사용하는 인자 이름이 필요없다면 단축 인자이름을 사용할 수 있다.
  • 클로저의 매개변수의 순서대로 $0, $1, $3 과 같이 사용한다.

암시적 반환 표현

// 암시적 반환 표현
// 클로저를 파라미터로 받는 함수에서 클로저의 반환형이 있다면
// 클로저의 마지막 줄의 결과값은 암시적으로 반환값으로 취급한다.
result = calculate(a: 10, b: 10) {
    $0 + $1
}
print(result) // 20

// 한줄 표현도 가능
result = calculate(a: 10, b: 10) { $0 + $1 }
print(result) // 20
  • 이전 함수에서 클로저의 반환형을 정의했다면, 굳이 반환하지 않아도 클로저의 마지막줄을 반환값으로 취급한다.

Capture

  • 앞에 Capture라는 말이 나왔는데 제대로 이해해보자.
  • 클로저는 내부함수와 내부함수에 영향을 미치는 주변 환경을 모두 포함한 객체이다.
  • 말이 어렵다.
 

func doSomething() {
    var message = "Hi i am sodeul!"
 
    //클로저 범위 시작
    
    var num = 10
    let closure = { print(num) }
 
    //클로저 범위 끝
    
    print(message)
}
  • 이 상황에서 message라는 변수는 클로저 안에서 사용하지 않으니 클로저 안에서 내부적으로 저장하지 않음
  • num은 사용하니, 해당 클로저에서 값을 저장함
  • 이 익명함수는 기본적으로 reference type으로 동작한다고 함
  • 이 때, num이라는 값을 클로저 내부적으로 저장한다는 것을 값이 캡쳐되었다. 라고 함
  • 그럼 값을 어떻게 캡쳐하는데?

Reference Capture

  • 값을 캡쳐할 때, Value/Reference 타입에 관계없이 Reference Capture 한다.
  • 일반적으로 struct, enum, int와 같은 타입은 값타입이다.
  • 즉 stack 영역에 저장이 된다는 것.
    • 사실 struct 값이 커지면 heap 에 저장되기도 함
  • 근데 클로저는 이 값들을 참조한다는 것
  • 일단 클로저는 heap 영역에 저장될 것이고(참조 타입)
  • 그 안에서 변수의 주소를 들고 있다는 것
  • 이를 Referece Capture라 함
    func doSomething() {
        var num: Int = 0
        print("num check #1 = \(num)")
        
        let closure = {
            print("num check #3 = \(num)")
        }
        
        num = 20
        print("num check #2 = \(num)")
        closure()
    }
    • 만약 이렇게 있다면, 클로저는 num 이라는 변수의 주소를 들고 있음
    • 이 말은, 해당 클로저에서 값을 변경하게되면 해당 변수의 값도 변경된다는 이야기

Value Capture (Capture list)

  • Value type으로 값을 캡쳐하자.

  • 클로저의 시작인 { 옆에 []을 이용해 캡쳐할 멤버를 나열한다.

    let closure = { [num1, num2] in
        // something..
    }
    • 값 타입으로 변수를 복사하고 싶은 경우 이렇게 리스트 형식으로 명시적으로 적어주면 된다.
    • 그런데 문제는, 이렇게 Value Type으로 캡쳐한 경우,
      • 선언할 당시의 num 값을 Const Value Type으로 캡쳐한다.
      • 아마 멀티 프로세스 환경에서 병렬 처리 목적으로 이러한 부분을 명시하지 않았나 싶다.
      • 따라서 변경이 불가하다.
      • 추가
        • 상태 값에 따라서 프로그래밍은 문제가 많이 발생한다.
        • 이런 방법을 사용하면 상태 값에 독립적으로 연산이 가능
  • 그럼 Reference Type을 Capture List에 작성하면 어떻게 되지?

    
    class Human {
        var name: String = "Sodeul"
    }
    
    var human1: Human = .init()
    
    let closure = { [human1] in
        print(human1.name)
    }
    
    human1.name = "Unknown"
    closure()
    • 이 코드에서 human1은 분명 reference type이다.
    • 이 상태에서 human1 을 캡쳐하면, 복사가 될까?
    • 출력 결과
      • Unknown
    • 안된다. 즉 여전히 Reference type은 Reference Capture한다.
    • 어떻게 하면 원하는 동작을 할 수 있게 할까?
      • 이렇게 class instance를 deep copy하고 싶은 경우 NSCopying 프로토콜을 사용한다.
      • copy라는 메서드를 통해, copy시 어떤 동작(아마 내부 property를 복사한 객체를 리턴하는 동작)을 수행할 것인지 정의할 수 있다.
      • shallow copy와 deep copy는 이글을 참고하자.

Closure와 ARC

  • Closure는 Reference type이면서, Capture한다는 특징 때문에, 메모리 관리로부터 자유로울 수 없다.
  • 특히 Swift에서 사용하는 ARC는 순환참조라는 문제를 야기할 수 있는데, 이러한 점에서 더더욱 Reference Type내에서 closure 작성에 유의해야 한다.
  • Class(Reference Type) 안에서 Closure(Reference Type)를 변수로 갖는 형태를 만들어 보자.
class Human {
    var name = ""
    // lazy : 사용되기 전까지는 연산이 되지 않음
    lazy var getName: () -> String = {
        return self.name
    }
    
    init(name: String) {
        self.name = name
    }
 
    deinit {
        print("Human Deinit!")
    }
}

var wansik: Human? = Human(name: "choiwansik")
print(wansik?.getName)
wansik = nil
  • lazy의 이유
    1. 쟤는 지금 프로퍼티임
    2. 프로퍼티는 인스턴스가 생성될 때 무조건 초기화가 되어있는 상태여야 함
    3. 멤버 메소드는 인스턴스가 초기화가 되어 있다는 가정이 되어 있기 때문에 해당 블록 안에서 self 사용이 가능
    4. 지금 쟤는 프로퍼티로서 할당이 되어 있는데, 초기화 단계에서 self 를 쓴다는 것이 말이 안됨
    5. 그러니까 self를 찾을 수 없다고 뜸
    6. 그렇기 때문에 lazy로 선언해야 해당 클래스가 동작함
  • 이렇게 만들어 놓고, getName이라는 지연 저장 프로퍼티를 호출해보았다.
  • 그리고 해당 wansik 인스턴스가 필요없어서 nil을 할당했다.
  • 내가 원하는 동작은, 지금 heap 공간에 있는 Human 인스턴스 주소를 wansik이라는 변수가 참조하고 있는데, 이 참조를 끊었으니 reference count가 0이되어 heap 공간에서 할당 해제 되는 것임
  • 하지만... deinit 함수는 호출되지 않는다. 왜일까?

클로저의 강한 순환 참조

  • 클로저는 참조 타입
  • Heap에 상주
  • getName을 호출하면, getName 클로저가 Heap에 할당
  • 그리고 그 클로저를 해당 Human 인스턴스의 프로퍼티가 참조
  • 자, 이상황이면, 클래스 인스턴스가 클로저를 참조, 클로저는 인스턴스를 참조
Human instanceclosure
2
wansik 변수, closure내에서 참조
human instance 변수인 getName이 참조
  • 이런 상황이 만들어짐
  • 그런데..! 이 때 RC를 보면 human instance의 RC가 2임
  • 응? 그럼 결국 closure가 강한 참조 를 한다는 것!
  • 와우 클래스 내에서 클로저를 만드는 경우 상당히 조심해야 함.

클로저의 강한 순환 참조 해결

  • 이제 배운 것을 다 섞어야 함
  • weak & unowned 와 캡쳐 리스트를 섞어야 함
  • 앞에서 캡쳐리스트를 써도 값 타입으로 복사가 안된다는 것을 기억할 것
  • 핵심은 간단하다.
    • 클로저에서 인스턴스를 참조할 때, 참조하는 인스턴스의 RC를 안늘려주면 되지.
class Human {
    lazy var getName: () -> String? = { [weak self] in
        return self?.name
    }
}

class Human {
    lazy var getName: () -> String = { [unowned self] in
        return self.name
    }
}
  • 차이점을 보면, weak의 경우 리턴이 옵셔널이다.
  • unowned의 경우 리턴이 옵셔널이 아니다.
  • weak인 경우, 무조건 옵셔널 타입이기 때문에 옵셔널 체이닝을 해주어야 한다.
  • 그리고 그렇기 때문에 리턴 타입도 옵셔널이어야 한다.
  • 그런데 unowned같은 경우에는 일단 Non optional type이다.
    • swift 5.0에서는 Optional Type이 되긴한다고 한다.
    • 하지만 기본은 그렇게 동작하지 않나보다.
  • 의미도 unowned는, 참조하는 인스턴스의 생존 길이가 더 길 때 사용한다고 되어 있기 때문에, 해당 값이 클로저를 실행하고 난후에 있다는 것이 보장된다.
  • 그렇기 때문에 리턴 타입을 옵셔널을 쓰지 않아도 된다!
  • 좀더 의미가 맞게 사용이 가능하고, 옵셔널 바인딩을 안써도 되니 코드가 깔끔해진다.

특수 Closure

@escaping

  • 기본적으로 함수의 인자로 넘어오는 closure의 경우, non-escaping closure이다.
    • 함수 내부에서 직접 실행하기 위해서만 사용된다.
    • 함수의 실행 흐름을 탈출하지 않고, 종료되기 전에 무조건 실행되어야 한다.
      • 함수의 실행 흐름이란, 함수의 기본 동작대로 행동해야 한다는 것이다.
      • 함수 실행 -> 실행 위치 주소 쌓임 -> parameter stack에 생성 -> 함수 끝 -> stack 비움 (parameter deinit)
      • closure가 parameter로 사용되었기 때문에, 함수 종료 시점에 사라져야 하는데, 다른 함수의 parameter로 들어간다면 이는 함수 흐름을 탈출한 것
    • 그래서~ 파라미터로 받은 클로저를 변수, 상수에 대입할 수 없다. (실행 목적이니까)
    • 중첩 함수에서 클로저를 사용할 경우 중첩 함수를 리턴할 수 없다.
    • 원래는 클로저는 1급 객체이기 때문에, 변수 할당, 리턴, 매개변수로 넘길 수 있어야 했는데 그게 안된다
  • 변수 및 상수 할당 불가
  • 함수의 흐름을 탈출하지 못함 (함수 종료 후 사라지지 않음)
    • 중첩 함수 내부에서 매개 변수로 받은 클로저를 사용 한 후, 중첩 함수를 반환하는 경우 반환 불가
  • 왜 필요할까?
    • 일반적으로 함수의 parameter의 동작과 다르게 움직이기 때문이다.
    • compiler가 해당 closure의 life cycle에 더 깊이 관여하어야 한다.
    • 계속해서 메모리 추적을 진행해야 한다.

@autoclosures

  • 함수의 인자로 전달되는 코드를 감싸서 자동으로 클로저로 만들어 줌

  • 조건

    • 인자로 사용되는 클로저는 parameter가 없고 return만 존재해야 함
    • () -> SomeType
  • non-escaping closure

    • 다른 함수의 parameter로 들어가거나 반환되어야 하는 경우 @escaping 추가 필요
  • 차이점

    func defaultPrint(_ closure: () -> String) {
        closure()
    }
    defaultPrint({ print("Default") }) // Default
    
    func autoClosurePrint(_ closure: @autoclosure () -> String) {
        closure()
    }
    autoClosurePrint(print("AutoClosure"))
    • 아래의 경우 {}가 없어져 보다 간결해졌다.
  • 사용 예시

    • UIView.animate
      UIView.animate(withDuration: 1) {
          self.view.frame.width = 100
      }
      func animate(_ animation: @autoclosure @escaping () -> Void, duration: TimeInterval) {
          UIView.animate(withDuration: duration, animations: animation)
      }
      animate(self.view.frame.width = 100, duration: 1)
      • UIView animate시 항상 코드가 두줄 이상이라 별로 였는데, 이런 경우 해당 코드 자체를 인자로 넘길 수 있다.
      • 인자로 넘기면서 {}를 없애어 보다 간결하게 표현할 수 있다.
    • Dictionary
      let isLiveInSea = True
      let legOfAnimals = ["tiger": 4, "chicken": 2, "squid": 10]
      let legOfSquid = legOfAnimals["squid"] ?? { isLiveInSea ? 0 : 4 }
      extension Dictionary {
          func value<T>(forKey key: Key, defaultValue: @autoclosure () -> T) -> T }
          guard let value = self[key] as? T else {
              return defaultValue()
          }
          return value
      }
      let legOfSquid = legOfAnimals.value(forKey: "squid", defaultValue: isLiveInSea ? 0 : 4)
      • 보다 간결하게 처리할 수 있다.
  • 유의 사항

    • 구문 작성 시점에 동작이 일어나지 않고, closure가 실행되는 시점에 동작 발생
      • 지연 실행이 필요할 때 유용
    • 가독성이 매우 떨어짐
      • 코드 실행이 읽는 위치에서 발생하지 않음
      • closure인지 확인하기 어려움
profile
목표와 계획, 그리고 실천의 힘을 믿습니다.

0개의 댓글