참조 타입의 캡처와 캡처 리스트

JIN·2023년 1월 26일
0

참조 타입의 캡처와 캡처 리스트

참조 타입의 캡처와 캡처 리스트는 값 타입과 비슷한 형태를 가지고 있지만, 내부 동작 및 결과에 차이점이 있습니다.

참조 타입의 캡처

클래스로부터 만들어진 인스턴스의 속성값 주소를 클로저가 참조할 때 속성값의 주소가 할당된 변수의 주소를 캡처 하여 클로저의 힙 영역에 저장합니다.

참조 타입의 캡처 리스트

클래스로부터 만들어진 인스턴스의 속성값 주소를 클로저 (캡처리스트)가 참조할 때 속성값의 주소 자체를 캡처하여 클로저의 힙 영역에 저장합니다.
(참조 타입에서 속성값의 주소 자체는 스택 영역에 있습니다)

참조 타입의 캡처와 캡처 리스트 코드 구현

class Number{
    var num: Int
   
    init(num: Int){
        self.num = num
    }
}

var A = Number(num: 10)   //인스턴스 A 생성과 동시에 RC(참조 카운팅) 1 증가
var B = Number(num: 10)   //인스턴스 B 생성과 동시에 RC(참조 카운팅) 1 증가
print("A의 값: \(A.num), B의 값: \(B.num)")   // A의 값: 10, B의 값: 10


var captureList = { [A] in   // 캡처 리스트를 위한 클로저 정의 + A 인스턴스의 RC 1 증가
    print("A(캡처 리스트)의 값: \(A.num), B의 값: \(B.num)")
}


A.num = 100    // 초깃값 변경
B.num = 100    // 초깃값 변경
captureList()  // A(캡처 리스트)의 값: 100, B의 값: 100


A.num = 777    // 캡처 리스트 클로저 동작 후 초깃값 변경
B.num = 777    // 캡처 리스트 클로저 동작 후 초깃값 변경
captureList()  // A(캡처 리스트)의 값: 777, B의 값: 777  =>  "A: 100, B: 777"이 아님

캡처 리스트를 통해 클로저의 힙 영역에 저장된 속성값이 변할 수 있었떤 이유는 근본적으로 참조 타입은 스택 영역의 값이 순수한 값이 아닌 힙을 참조하는 주솟값이기 떄문이다.

강한 참조 사이클의 문제 해결

클래스로부터 만들어진 인스턴스(객체)또는 클로저가 서로를 참조하여 발생하는 문제이다.

class Man{
    var name: String
    var run: (()->Void)?
   
    init(name: String){
        self.name = name
    }
   
    func runClosure(){
        run = {
            print("\(self.name)이 달리고 있습니다.")
        }
    }
   
    deinit{
        print("\(self.name) 메모리에서 제거되었습니다.")
    }
}

func doSomething(){
    var kim: Man? = Man(name: "김철수")  // kim 인스턴스 생성 (kim RC 1증가)
    kim?.runClosure()  // 클로저(run)가 메모리의 Heap 영역에 생성
}

doSomething()  // 아무 출력 없음
  1. doSomething() 함수 작동
  2. kim 인스턴스 생성(kim RC 1 증가)
  3. kim 인스턴스가 runClosure() 함수 작동
  4. runClosure() 함수에 의해 클로저(run)가 메모리의 힙 영역에 생성(클로저 rc 1증가)
  5. runClosure() 함수에서 클로저가 kim을 지목해서 참조하고 있음(kim RC 1증가)
  6. doSomethig() 함수의 실행이 종료(kim RC 1감소)

-> 인스턴스의 카운트는 1, 클로저의 카운트는 1
-> kim 인스턴스와 클로저가 강한 참조 사이클을 유지하고 있기 때문에 소멸자가 동작하고 있지 않음

메모리 누수 해결 방법

  1. 캡처 리스트 + 약한 참조
  • 서로를 가리키는 인스턴스의 카운트를 세지 않는 방식
  • 참조하고 있던 인스턴스가 메모리에서 제거되면, 참조했던 다른 한쪽의 인스턴스는 nilfh chrlghkehlsek.
  • 약한 참조는 var로만 정의 할 수 있고 옵셔널 타입으로만 정의해야 한다.
class Man{
    var name: String
    var run: (()->Void)?
   
    init(name: String){
        self.name = name
    }
       
    func runClosure(){
        run = { [weak self] in
            print("\(self?.name)이 달리고 있습니다.")
        }
    }
   
    deinit{
        print("\(self.name) 메모리에서 제거되었습니다.")
    }
}

func doSomething(){
    var kim: Man? = Man(name: "김철수")  // kim 인스턴스 생성 (kim RC 1증가)
    kim?.runClosure()  // 클로저(run)가 메모리의 Heap 영역에 생성
}

doSomething()  // 김철수 메모리에서 제거되었습니다.
  1. doSomething() 함수 작동
  2. kim 인스턴스 생성 (RC1 추가)
  3. kim 인스턴스가 runClosure() 함수 작동
  4. runClosure() 함수에 의해 클로저가 메모리의 힙 영역에 생성(RC 0 증가)
  5. runClosure() 클로저가 kim을 지목하여 참조하고 있음(kim RC 1증가)
  6. doSomethig() 함수의 실행이 종료, 함수가 종료됨에 따라 kim 인스턴스가 메모리에서 제거 되기 때문에 클로저또한 메모리에서 제거(kim RC 1감소)
    -> 인스턴스의 카운트는 0, 클로저의 카운트는 0
    -> kim 인스턴스와 클로저가 참조 카운트가 0이 되었기 때문에 소멸자가 동작

Completion Handler

Traling Closure

후행클로저는 함수의 마지막 인자로 클로저 표현식을 함수에 전달하거나 클로저 표현식이 긴 경우에 사용한다.

func travel(action: () -> Void) {
	print("I'm getting ready to go.")
    action()
    print("I arrived")
}

위 travel 메서드는 인자로 action이라는 Closure을 채택하고 있다. action은 함수 내부에서 두번의 프린트 사이에 실행된다. 우리는 위 travel 메서드를 아래와 같은 방식으로 사용할 수 있다. (인자가 없기 때문에 () 생략 가능)

travel() {
  print("I'm driving in my car")
}

Completion Handler?

사용자가 앱을 사용하는 동안에 앱을 업데이트를 진행하고 업데이트가 끝나면 이를 반드시 사용자에게 알려야 하는 상황이라면 업데이트가 끝났음을 알려야한다. 이렇듯 Completion Handler는 어떠한 일이 끝났을 때 진행할 업무를 담당한다.

Introduce

ViewController로 화면을 전환하는 코드

import UIKit
let firstVC = UIViewController()
let nextVC = UIViewController()

firstVC.present(nextVC, animated: true, completion: nil)

completion 파라미더에는 () -> () 또는 () -> Void 타입의 클로저 블락을 사용할 수 있다. 아래와 같이 completion 파라미터에 클로저를 적용시켜 본다.

firstVC.present(nextVC, animated: true, completion: { () in print("화면 전환 완료")})
firstVC.present(nextVC, animated: true, completion: { print("화면 전환 완료")})
// traling Closure
firstVC.present(nextVC, animated: true){ print("화면 전환 완료")}

위 메서드가 정상적으로 실행되면 nextVC 가 팝업되면서 콘솔창에는 "화면전환 완료"라는 문자열이 출력된다.

Completion Handler 디자인

let handleBlock: (Bool) -> Void = { doneWork in
  if doneWork {
  print("퇴근하겠습니다")
  }
}
handleBlock(true) // 퇴근하겠습니다.
let handleBlock: (Bool) -> Void = { 
  if $0 {
  print("퇴근하겠습니다")
  }
}
handleBlock(true) // 퇴근하겠습니다.

Completion Handler를 통한 데이터 전달

Alamofire.request("https://google.com").responseJSON { response in 
  print(response)
}

메서드의 request가 서버로 전송되어 이에 대한 결과값이 response 인자에 담겨 이를 클로저 내부에서 사용할 수 있게 된다.
이렇듯 swift를 이용하다보면 함수의 마지막 인자로 Closure를 적용하는 경우를 심심치 않게 살펴볼 수 있다.
이는 함수가 종료된 직후에 이벤트 처리를 위해 매우 많이 사용된다.

클로저가 함수의 인자로 전달됐을 때, 함수의 실행이 종료된 후 실행되는 클로저, Non-Escaping 클로저는 이와 반대로 함수의 실행이 종료되기 전에 실행되는 클로저

Non-Escaping Closure

func runClosure(closure: () -> Void) {
	closure()
}
  1. 클로저가 runClosure() 함수의 closure 인자로 전달됨
  2. 함수 안에서 closure()가 실행됨
  3. runClosure()함수가 값을 반환하고 종료됨

이렇게 클로저가 함수가 종료되기 전에 실행되기 때문에 closure는 Non-Escaping 클로저 입니다.

Escaping Closure

class ViewModel {
	var completionhandler: (() -> Void)? = nil
    
    func fetchData(completion: @escaping () -> Void) {
    completionHadler = completion
    }
}
  1. 클로저가 fetchData() 함수의 completion 인자로 전달됨
  2. 클로저 completion이 completionhandler 변수에 저장됨
  3. fetchData() 함수가 값을 반환하고 종료됨
  4. 클로저 completion은 아직 실행되지 않음
    completion은 함수의 실행이 종료되기 전에 실행되지 않기 때문에 escaping 클로저, 다시말해 함수 밖에서 실행되는 클로저입니다.
    escaping 클로저가 사용되는 흔한 예로는 비동기로 실행되는 HTTP Request CompletionHandler가 있습니다.

매번 escaping을 사용해주지 않는 이유는 뭘까?

컴파일러의 퍼포먼스와 최적화때문.
non-escaping 클로저는 컴파일러가 클로저의 실행이 언제 종료되는지 알기 때문에 때에 따라 클로저에서 사용하는 특정 객체에 개한 retain, release 등의 처리를 생략해 객체의 라이프 사이클을 효율적으로 관리할 수 있다.
반면 escaping 클로저는 함수 밖에서 실행되기 때문에 클로저가 함수 밖에서도 적절히 싱행되는 것을 보장하기 위해 추가적인 참조 사이클을 관리해주어야 한다. 이는 퍼포먼스에 영향을 미치기 때문에 필요할때만 사용해야 한다.

Result Type으로 명확한 결과값 만들기

Swift 2 시절

새로운 에러 처리 모델이 도입되었고 현재까지 사용되고 있다.
에러가 발생할 수 있는 코드를 throwing function으로 선언하고 do-catch 문에서 try 표현식을 통해 함수를 호출하고 발생한 에러를 처리한다. 에러형식은 특별한 프로토콜을 채용한 형식으로 선언한다.

enum MyError: ErrorType {
	case someError
    case criticalError
}
func doSomething() throws {
	throw MyError.someError
}
do {
	try doSomething() 
} catch let myError as MyError {
	switch myError {
    case .someError:
    	print("someError")
    case .criticalError:
    	print("criticalError")
    }
} catch {
print(error.localizedDescription)
}

이 방식의 한계

  • throws 코드 블록에서 에러를 던질수 있다는 걸 나타내지만 에러의 형식은 특정할 수 없음
  • catch로 올때 실제 에러가 아닌 에러 프로토콜 형식으로 전달되는데, 이때 모호함이 발생함
  • 에러를 처리하기 위해 어떤 형식의 에러를 던지는 지 파악한 후 해당 형식으로 타입캐스팅 해줘야함
  • 새로운 에러 형식이 추가되어도 컴파일러는 인지할 수 없음

Result Type

새롭게 도입된 Result Generic Enumeration으로 선언되어 있다

public enum Result<Success, Failure> where Failure: Error

형식이 정해져 있다 즉 형식에 관한 모호함이 사라지게 된다. Result는 성공과 실패 2가지가 존재한다. Success에는 작업의 결과가 저장되고 Failure에는 에러가 저장된다.

Result Type의 장점

func isOddNumber(number: Int) -> Result<Int, NumberError> {
	guard number >= 0 else {
    	return Result.failure(NumberError.negativeNumber)
    guard !number.isMultiple(of: 2) else {
    	return .faulure(.evenNumber)
      }
      return .success(oddNumber * 2)
}


let result = isOddNumber(number: 1)
switch result {
case .success(let data):
	print(data)
case .failure(let error):
	print(error.localization)
 }
  • 에러 형식이 명시적으로 선언된다.
  • 타입캐스팅 없이 에러 처리가 가능하다
  • 형식 추론을 통해 에러처리 코드가 단순해진다
  • 작업의 결과를 성공과 실패로 명확히 구분 가능하다.
  • get 메서드로 에러처리코드를 더욱 단순하게 구현 가능하다
if let successResult = try? result.get() {
  print(successResult)
}
  • 기존 에러 처리 패턴을 대체하는 것이 아니라 에러를 처리하는 방식이 다양해진것
profile
배우고 적용하고 개선하기

0개의 댓글