Swift

Swift - 클로저

호종이 2022. 2. 15. 20:51

스위프트의 클로저 는 함수형 프로그래밍 패러다임을 이해하기 위해 꼭 알고 가야 하는 요소다.  클로저는 다른 프로그래밍 언어의 람다와 유사하고, 일정 기능을 하는 코드를 하나의 블록으로 모아놓은 것을 의미한다. 클로저는 함수의 한 형태이며, 사실 함수는 이름이 있는 클로저라고 할 수 있다.

 

클로저는 변수나 상수가 선언된 위치에서 참조를 획득할 수 있다. 이를 변수나 상수의 클로징(잠금) 이라 하며, 클로저는 여기서 착안된 이름이다.

클로저는 다음과 같이 세 가지 형태가 있다.

  1. 이름이 있으면서 어떤 값도 획득하지 않는 전역함수
  2. 이름이 있으면서 다른 함수 내부의 값을 획득할 수 있는 중첩된 함수
  3. 이름이 없고 주변 문맥에 따라 값을 획득할 수 있는 축약 문법으로 작성

 

기본 클로저


스위프트의 표준 라이브러리에는 배열의 값을 정렬하기 위해 구현한 sorted(by:) 란 메소드가 있다. 이 메소드는 클로저를 통해 정렬 기준을 정하는 정보를 받아 해당 클로저를 기반으로 배열을 정렬한다. Java 나 Kotlin 에서 ArrayList 같은 컬렉션이나 배열을 정렬할 때 Comparator 인터페이스를 구현한 구현체를 정렬 함수에 넘겨줬듯이, Swift 도 마찬가지로 정렬하는데 사용할 함수를 넘겨주는 것이다.

 

sorted(by:) 메소드의 정의를 한 번 보도록 하자. 

public func sorted(by areInIncreasingOrder: (Element, Element) throws -> Bool) rethrows -> [Element]

sorted(by:) 함수는 인자로 (Element,Element) -> Bool 타입의 함수를 받는 것을 알 수 있다. areInIncreasingOrder 함수의 주석을 보면, 

- Parameter areInIncreasingOrder: A predicate that returns `true` if its
first argument should be ordered before its second argument;
otherwise, `false`.

즉 첫 번째 인자가 두 번째 인자보다 앞에 있어야 한다면 true 를, 아니면 false 를 반환하는 함수라고 정의했다. 그렇다면 다음과 같은 배열을 오름차순으로 정렬하고 싶을 때, 다음과 같은 클로저를 넘겨줄 수 있다.

{ (a:Int,b:Int) -> Bool in
    if a > b {
        return false
    } else {
        return true
    }
}

우선 클로저의 맨 앞에는 매개변수와 반환 타입이 온다. in 키워드 다음에 클로저에서 실행할 코드를 기술하는데, a를 배열의 앞에 있는 데이터, b를 배열의 뒤에 있는 데이터라고 할 때 a 가 b보다 크면 a는 b보다 뒤에 가야 하므로 false 를, 아니면 true 를 반환하는 클로저를 작성하였다. 

 

클로저의 타입이 이미 정해져 있으므로, 사실 매개변수와 반환 타입을 다음과 같이 기술해도 된다. 

{ a,b in
    if a > b {
        return false
    } else {
        return true
    }
}

두 개의 Element 타입과 Bool 값을 반환하는 타입의 클로저라는 것을 이미 컴파일러가 알기 때문에, 매개변수는 자동으로 Element 타입이 되고 Bool 값을 반환할 수 있다. 

 

var list = [9,8,7,6,5,4,3,2,1]

var sortedArray = list.sorted(by: { a,b in
    if a > b {
        return false
    } else {
        return true
    }
})


print(sortedArray.description)

//출력결과
[1, 2, 3, 4, 5, 6, 7, 8, 9]
클로저의 표현 

{ (매개변수들) -> 반환 타입 in 
    실행 코드
}

후행 클로저


후행 클로저는 코드 가독성을 좀 더 개선하기 위해 나온 기술로, 만약 함수나 메소드의 마지막 인자가 함수 타입일 경우, 해당 함수를 호출할 때 소괄호를 닫고 클로저를 작성해도 된다. 아래 예제를 보자 

var list = [9,8,7,6,5,4,3,2,1]

//sorted 함수는 하나의 함수만 받기 때문에, 바로 클로저를 작성할 수 있다.
var sortedArray = list.sorted { a,b in
    return a > b ? false : true
}

print(sortedArray.description)

sorted 함수는 하나의 함수만 받기 때문에, 클로저를 바로 작성할 수 있다. 그렇다면 하나의 문자열과 하나의 (String)->Void 타입의 함수를 받는 함수는 어떻게 호출할까?

func testFunction(string : String, function: (String) -> Void){
    function(string)
}

testFunction(string: "Hello Swift", function: {string in print(string)})

물론 위와 같이 클로저를 함수 소괄호 안에서 작성할 수도 있지만, 만약 클로저가 매우 길어질 경우 가독성이 떨어지는 단점이 있다. 마지막 클로저를 밖으로 추출해보자. 

testFunction(string: "Hello Swift"){ string in
    print(string)
}

클로저 표현 간소화


메소드의 전달인자로 전달하는 클로저는 메소드에서 요구하는 형태로 전달해야 한다. 즉 매개변수의 타입이나 개수, 반환 타입 등이 같아야 전달인자로서 전달할 수 있다. 즉 컴파일러가 이미 클로저의 타입을 알고 있고, 그러므로 클로저의 표현식을 극도로 간소화하는게 가능하다. 

 

우리는 이미 이전에 sorted 함수의 인자로 넘길 클로저를 다음과 같이 간소화한 경험이 있다. 

{ (a:Int,b:Int) -> Bool in
    if a > b {
        return false
    } else {
        return true
    }
}
-> 매개변수 타입과, 반환 타입 생략! 
{ a,b in
    if a > b {
        return false
    } else {
        return true
    }
}

매개변수 이름 생략

그러나 위에서 매개 변수의 이름조차 생략할 수 있다. 이름을 생략할 시 매개변수는 매개 변수의 위치에 따라 $0, $1, $2 순서로 $와 숫자의 조합으로 표현한다. 

즉 위의 sorted 클로저는 다음과 같이 간소화할 수 있다. 

var sortedArray = list.sorted {
    return $0 <$1
}

$0이 첫 번째 매개변수, $1 이 두 번째 매개변수가 되는 것이다.

 

암시적 반환 표현

클로저에 하나의 표현식 밖에 없는 경우, 컴파일러는 해당 표현식을 명시적으로 반환 타입으로 생각한다. 즉 위의 sorted 함수를 다음과 같이 줄일 수 있다. 

var sortedArray = list.sorted {
    $0 < $1
}

$0 < $1 이라는 하나의 표현식만 존재하고, 해당 표현식은 Bool 값을 반환하기 때문에 위와 같은 표현이 가능한 것이다. 

값 획득


클로저는 자신이 정의된 위치의 주변 문맥, 즉 자기가 속한 코드 블록 안의 상수나 변수를 획득할 수 있다. 값 획득을 통해 클로저는 주변에 정의한 상수나 변수가 더 이상 존재하지 않더라도 해당 변수나 상수의 값을 자신 내부에서 참조하거나 수정할 수 있다. 이 같은 특성을 이용해서 클로저는 주로 비동기 작업의 콜백 메소드로 자주 사용된다. 비동기 작업의 콜백 메소드는 메소드를 실행하는 순간에는 주변의 상수나 변수가 이미 메모리에 존재하지 않는 경우가 발생하기 때문이다. 

 

incrementer 함수를 중첩 함수로 포함하는 makeIncrementer 함수를 살펴보자.

//() -> Int 타입의 함수를 반환한다는 의미!
func makeIncrementer(forIncrement amount : Int) -> (() -> Int) {
    var runningTotal = 0
    func incrementer() -> Int {
        runningTotal += amount
        return runningTotal
    }
    return incrementer
}

incrementer 함수는 자기 주변에 있는 runningTotal 과 amount 라는 값을 획득한다. 두 값을 획득한 후에 incrementer 는 클로저로서 makeIncrementer 함수에 의해 반환된다. 

 

이제 makeIncrementer 함수에 의해 반환되는 incrementer 함수를 상수에 저장해보자.

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

해당 함수는 Int 를 반환하는 함수이기 때문에, incrementByTwo() 함수를 호출할 때마다 Int 를 반환할 것이다. 해당 함수 호출 결과를 저장하는 상수를 세 개 만들어보자. 

let first : Int = incrementByTwo()
let second : Int = incrementByTwo()
let third : Int = incrementByTwo()

//출력 결과
2
4
6

incrementer 함수는 아무런 매개변수도 갖지 않고, 아무런 지역변수도 갖지 않지만, 함수 생성 시점에 주변 runningTotal 과 amount 의 참조를 획득해서 함수를 호출할 때마다 계속 증가하는 값을 반환하는 것을 보여준다. 각각의 함수는 자신만의 runningTotal 의 참조를 획득했기 때문에, 다음과 같이 여러개의 incrementer 함수를 사용하더라도 서로 영향을 받지 않는다.

 

let incrementByTwo = makeIncrementer(forIncrement: 2) //2씩 증가하는 함수
let incrementByTen = makeIncrementer(forIncrement: 10) //10씩 증가하는 함수

let first : Int = incrementByTwo() //2
let second : Int = incrementByTwo() //4
let third : Int = incrementByTwo() //6

let first2 : Int = incrementByTen() //10
let second2 : Int = incrementByTen() //20
let third2 : Int = incrementByTen() //30

클로저는 참조 타입이다


클로저는 참조 타입이기 때문에, 변수나 상수에 클로저를 할당하는 것은 참조값을 할당하는 것과 똑같다. 다음 코드를 실행해보자.

let incrementByTwo = makeIncrementer(forIncrement: 2) //2씩 증가하는 함수
let copy = incrementByTwo

let first = incrementByTwo()
let second = copy()

print(first)
print(second)

//출력 결과
2
4

copy 상수에 incrementByTwo 의 참조값을 할당했으므로, incrementByTwo 와 copy 상수는 둘 다 똑같은 함수를 참조하고 있다. 따라서 second 상수가 4의 값을 가지게 된다.

탈출 클로저


함수의 전달인자로 전달한 클로저가 함수 종료 후에 호출될 때 클로저가 함수를 탈출한다고 표현한다. 클로저를 매개변수로 갖는 함수를 선언할 때 매개변수 이름의 콜론(:) 뒤에 @escaping 키워드를 사용하여 클로저가 탈출하는 것을 허용한다고 명시해줄 수 있다. 

 

그렇다면 탈출 클로저는 어디에 쓸 수 있을까? 디비 작업이나 네트워크 작업같은 비동기 작업들은 완료 콜백을 전달인자로 받아온다. 비동기 작업으로 함수가 종료되고 난 후 호출할 필요가 있는 클로저를 사용해야 할 때 탈출 클로저가 필요하다. 

 

위에서 설명한 sorted 메소드의 함수에는 @escaping 키워드를 찾아볼 수 없다. 해당 클로저는 비탈출 클로저이기 때문이다. 해당 클로저는 함수가 종료된 후 실행될 필요가 없다.

 

아래 예제를 보자

var completionHandlers : [() -> Void] = []

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

completionHandlers 는 () -> Void 타입의 클로저를 저장하는 배열이고, someFunctionWithEscapingClosure 함수는 () -> Void 타입의 클로저를 받아서 배열에 저장하는 함수다. 

 

아래 예제를 통해 탈출 클로저를 조금 더 살펴보자

typealias VoidVoidClosure = () -> Void
let firstClosure : VoidVoidClosure = {
    print("Closure A")
}
let secondClosure : VoidVoidClosure = {
    print("Closure B")
}

//first 와 second 매개변수는 함수의 반환 값으로 사용될 수 있으므로 탈출 클로저로 명시해야함
func returnOneClosure(first:@escaping VoidVoidClosure, second: @escaping VoidVoidClosure, shouldReturnFirstClosure:Bool) -> VoidVoidClosure {
    //전달받은 클로저를 다시 반환하기 때문에 escaping 클로저여야 함
    return shouldReturnFirstClosure ? first:second
}

let returnedClosure : VoidVoidClosure = returnOneClosure(first: firstClosure, second: secondClosure, shouldReturnFirstClosure: true)

returnedClosure() //closure A

var closures: [VoidVoidClosure] = []

func appendClosure(closure:@escaping VoidVoidClosure) {
    //배열에 저장되서 함수를 탈출하므로 탈출 클로저임
    closures.append(closure)
}