스위프트에서 클로저는 일회용 함수를 작성할 수 있는 구문이다. 일회용 함수란 한 번만 사용할 구문들의 집합이면서, 그 형식은 함수로 작성되어야 하는 제약조건이 있을 때 만들어 사용할 수 있는 함수를 얘기한다. 익명 함수라고 부르기도 한다. 스위프트에서 클로저라고 부르는 객체는 대부분 다음 세 가지 경우 중 하나에 해당된다.
- 전역 함수 - 이름이 있으며, 주변 환경에서 캡쳐할 어떤 값도 없는 클로저
- 중첩 함수 - 일므이 있으며 자신을 둘러싼 함수로부터 값을 캡쳐할 수 있는 클로저
- 클로저 표현식 - 이름이 없으며 주변 환경으로부터 값을 캡쳐할 수 있는 경량 문법으로 작성된 클로저
클로저 표현식
클로저 표현식은 함수와 달리 생략되는 부분이 많다.우선 func 키워드를 생략하며, 함수명 또한 생략한다.
{ (매개변수) -> 반환타입 in
실행할 구문
}
in을 기점으로 in 앞쪽에는 함수의 타입이, 뒤쪽에는 실행할 구문이 작성된다. 클로저의 표현식의 실행 블록 작성이 끝나면 중괄호로 닫으면 된다. 아래는 실제 사용 형식이다.
{ () -> () in
print("클로저가 실행됩니다.")
}
위 예제의 함수는 인자값과 반환값이 없는 함수이다. 반환값이 없을 때는 일반 함수처럼 반환값 타입을 생략하는 것이 아니라, 명시적으로 표현해야 한다. 이는 클로저 형식의 모호성을 제거하기 위한 규칙이다.
작성된 클로저 표현식은 그 자체로 함수라고 할 수 있다. 클로저 표현식은 대부분 인자값으로 함수를 넘겨주어야 할 때 사용하지만, 직접 실행해볼 수도 있다. 이를 위한 두 가지 방식이 있는데 첫 번째는 일급 함수로서의 특성을 활용하여 상수나 변수에 클로저 표현식을 할당한 다음 실행하는 방법이다.
let f = { () -> Void in
print("클로저가 실행됩니다.")
}
f() // 클로저가 실행됩니다.
두 번째 방법은 상수 f마저 생략하는 구문이다.
({ () -> Void in
print("클로저가 실행됩니다.")
})()
소괄호 내부에 클로저를 작성하고 뒤에 호출 연산자(소괄호)를 붙이면 클로저 표현식이 실행된다.
이번에는 매개변수가 있는 클로저 표현식을 작성한 예시이다.
let c = { (s1: Int, s2: String) -> Void in
print("s1:\(s1), s2:\(s2)")
}
c(1, "closure") // s1:1, s2:slosure
이 클로저 표현식은 정수와 문자열 두 개의 인자를 각각 s1, s2라는 매개변수로 받는다. 이 매개변수느 클로저의 실행 블록 내부에서 상수로 선언되므로 실행 구문 범위 내에서 사용할 수 있다. 위 예제는 더 간결하게 작성할 수 있다.
({ (s1: Int, s2: String) -> Void in
print("s1:\(s1), s2:\(s2)")
})(1, "closure")
더 간결하긴 하지만 가독성이 떨어진다. 클로저의 경우 형태에 따라 간결성과 가독성이 달라지므로 적당한 수준을 고려하여 작성해야 한다.
클로저 표현식과 경량 문법
클로저 표현식은 주로 인자값으로 사용되는 객체인 만큼, 간결성을 극대화하기 위해 생략할 수 있는 구문들로 이루어져 있다. 필요에 따라 여러 부분을 생략할 수 있다. 배열의 정렬 메소드 예제를 통해 실제로 클로저 표현식에 적용되는 경량문법에 대해 더욱 알아볼 것이다.
아래는 정수로 이루어져 있고 정렬되지 않은 배열이다.
var value = [1, 9, 5, 7, 3, 2]
기본적으로 정렬은 두 값의 비교를 반복하는 알고리즘이다. 작은 값을 앞으로, 큰 값을 뒤로 배치하는 과정을 반복한다. 즉 정령의 기준으 두 개의 값을 비교하여 어느 것이 더 큰지만 판단할 수 있으면 충분하다. 정렬 기준이 되는 함수를 작성하고, 이를 sort(by:)메소드의 인자로 넣어 정렬할 것이다.
func order(s1: Int, s2: Int) -> Bool {
if s1 > s2 {
return true
}else {
return false
}
}
value.sort(by: order)
// [9, 7, 5, 3, 2, 1]
함수 order는 두 인자값을 비교해 첫 번째 인자가 크면 true, 아니면 false를 반환한다. 이 기준에 따라 내림차순 정렬이 실행된다. 이번에는 order를 클로저 표현식으로 작성한 것이다.
{
(s1: Int, s2: Int) -> Bool in
if s1 > s2 {
return true
}else {
return false
}
}
value.sort(by: {
(s1: Int, s2: Int) -> Bool in
if s1 > s2 {
return true
}else {
return false
}
})
이를 더 간결한 표현식으로 작성한 것이다.
{ (s1: Int, s2: Int) -> Bool in
return s1 > s2
}
value.sort(by: { (s1: Int, s2: Int) -> Bool in return s1 > s2 })
s1 > s2 구문은 Bool 값을 리턴하므로 코드가 간결하게 바뀌었다. 이번에는 타입 어노테이션을 제거하고 더 간결하게 바꿔볼 것이다.
{ s1, s2 in return s1 > s2}
value.sort(by: { s1, s2 in return s1 > s2})
마지막으로 매개변수마저 생략할 수 있다. 매개변수명 대신 $0, $1, $2... 와 같은 이름으로 내부 상수를 이용할 수 있다. 이 값은 입력받은 인자값의 순서대로 매칭된다. 첫 번째 인자값이 $0, 두 번째 인자값이 $1에 할당된다. 매개변수가 생략되면 실행구문만 남는다. 이 때문에 in 키워드로 실행 구문과 선언 구문을 분리할 필요도 없어지므로 in 키워드도 지워진다. 결국 남는것은 다음과 같다.
{ return $0 > $1 }
여기서 return도 생략할 수 있다. return을 생략해서 적용하면 아래와 같다.
value.sort(by: { $0 > $1 })
sort 메소드에서는 클로저 표현식보다 더 간결하게 표현할 수 있는 방법도 있다. 연산자 함수라고 부르는데, 연산자만을 사용하여 의미하는 바를 정확히 나타낼 수 있을 때 사용한다.
value.sort(by: >)
이렇게 다양한 형태로 클로저를 사용할 수 있다.
트레일링 클로저(Trailing Closure)
트레일링 클로저는 함수의 마지막 인자값이 클로저일 때, 이를 인자값 형식으로 작성하는 대신 함수의 뒤에 꼬리처럼 붙일 수 있는 문법을 의미한다. 이 때 인자 레이블은 생략된다. 주의할 점은 이 문법이 함수의 마지막 인자값에만 적용된다는 것이다. 만약 인자값이 하나라면 이는 첫번째 인자값이지만 동시에 마지막 인자값이므로 트레일링 클로저 문법을 사용할 수 있다.
value.sort() { (s1, s2) in
return s1 > s2
}
자세히 살펴 보면 인자값으로 사용되던 클로저가 통째로 바깥으로 빼내어진 다음, sort() 메소드의 뒤쪽에 달라붙은 것을 알 수 있다. 이로 인해 얻는 장점은 코딩 과정에서 sort() 함수를 열고 닫는 범위가 줄어든다는 것이다. 인자값이 하나일 경우 트레일링 클로저 문법은 조금 더 변화할 여지가 있다.
value.sort { (s1, s2) in
return s1 > s2
}
이번에는 sort 메소드 뒤에 괄호가 아예 사라졌다. 더 필요한 인자값도 없고, 트레일링 클로저 문법 덕분에 호출 구문이라는 것을 명확히 할 수 있어서 굳이 괄호의 필요성이 없기 때문이다. 만약 인자값이 여러 개라면 무작정 괄호를 생략해서는 안된다.
func divide(base: Int, success s: () -> Void) -> Int {
defer {
s() // 성공 함수를 실행한다.
}
return 100 / base
}
함수 부분에서 사용했던 divide 함수의 변형이다. 두 개의 인자값을 받는데 마지막 인자값에 클로저를 넣을 수 있으므로 위 함수는 트레일링 클로저를 사용할 수 있는 조건이 충족된다. 호출하는 구문은 다음과 같다.
divide(base: 100) { () in
print("연산에 성공했습니다.")
}
만약 두 개의 클로저 인자값이 사용될 경우 트레일링 클로저도 연이어 적용할 수 있을지 생각할지 모르지만, 불가능하다.
아래는 예제 코드이다.
func divide(base: Int, success s: () -> Void, fail f: () -> Void) -> Int {
guard base != 0 else {
f() // 실패 함수를 실행한다
return 0
}
defer {
s() // 성공 함수를 실행한다
}
return 100 / base
}
아래는 호출하는 구문이다.
divide(base: 100, success: { () in
print("연산이 성공했습니다.")
}) { () in
print("연산이 성공했습니다.")
}
이렇게 success 는 마지막 인자값이 아니므로 트레일링 클로저를 사용할 수 없다.
@escaping 과 @autoclosure
클로저를 함수나 메소드의 인자값으로 사용할 때에는 용도에 따라 @escaping과 @autoclosure 속성을 부여할 수 있다.
@escaping
@escaping 속성은 인자값으로 전달되 클로저를 전달해 두었다가 나중에 다른 곳에서도 실행할 수 있도록 허용해주는 속성이다. 아래는 예제 코드이다.
func callback(fn: () -> Void) {
fn()
}
callback {
print("Closure가 실행되었습니다.")
}
// Closure가 생성되었습니다.
정의된 함수 callback(fn:)은 매개변수 전달을 통해 전달된 클로저를 함수 내부에서 실행하는 역할을 한다. 이번에는 이 코드를 아래와 같이 바꿔볼 것이다.
func callback(fn: () -> Void) {
let f = fn // 클로저를 상수 f 에 대입
f() // 대입된 클로저를 실행
}
이러면 let f = fn 이 구문에서 오류가 발생한다. 오루의 내용은 "Non-escaping 파라미터인 fn은 오직 직접 호출하는 것만 가능하다." 는 의미이다. 이를 이해하기 위해서는 인자값으로 전달되는 클로저의 특성을 알아야 한다.
스위프트에서 함수의 인자값으로 전달된 클로저는 기본적으로 탈출불가(non-escape)의 성격을 가진다. 이는 해당 클로저를 1. 함수 내에서 2. 직접 실행을 위해서만 사용해야 하는 것을 의미하며, 함수 내부라 해도 변수나 상수에 대입할 수 없다. 변수나 상수에 대입이허용되면 내부 함수를 통한 캡처 기능을 활용해 클로저가 함수 바깥으로 탈출할 수 있기 때문이다. 탈출이란 ㅎ마수 내부 범위를 벗어나서 실행되는 것을 의미한다.
동일한 의미에서 인자값으로 전달된 클로저느 중첩된 내부 함수에서 사용할 수도 없다. 내부 함수에서 사용할 수 있도록 허용할 경우, 이 역시 컨텍스트 캡처를 통해 탈출될 수 있기 때문이다.
하지만 코드를 작성하다 보면 클로저를 변수나 상수에 대입하거나 중첩 함수 내부에서 사용해야 할 경우도 있다. 이 때 사용되는 것이 @escaping 속성이다. 아래는 앞서 작성했던 callback(fn:) 함수 매개변수 타입에 @escpaing 속성을 추가한 코드이다.
func callback(fn: @escaping () -> Void) {
let f = fn // 클로저를 상수 f에 대입
f()
}
call back {
print("Closure가 실행되었습니다.")
}
// Closure가 실행되었습니다.
이제 입력된 클로저느 변수나 상수에 정상적으로 할당될 뿐만 아니라, 중첩된 내부 함수에 사용할 수 있으며, 함수 바깥으로 전달도 할 수 있다. 말 그대로 탈출 가능한 클로저가 된 것이다.
클로저의 기본 속성을 탈출불가(Non-escape) 하게 관리함으로써 얻어지는 가장 큰 이점은 컴파일러가 코드를 최적화하는 과정에서의 성능 향상이다. 해당 클로저가 탈출할 수 없다는 것은 컴파일러가 더 이상 메모리 관리상의 일들에 관여할 필요가 없다는 뜻이기 때문이다.
또한 탈출불가 클로저 내에서의 self 키워드를 사용할 수 있다. 왜냐하면 이 클로저는 해당 함수가 끝나서 리턴되기 전에 호출될 것이 명확하기 때문이다. 따라서 클로저 내에서 self에 대한 약한 참조(weak reference)를 사용해야할 필요가 없다.
@autoclosure
@autoclosure 속성은 인자값으로 전달된 일반 구문이나 함수 등을 클로저로 래핑(Wrapping)하는 역할을 한다. 쉽게 말해 이 속성이 붙어 있다면 일반 구문을 인자값으로 넣더라도 컴파일러가 알아서 클로저로 만들어 사용한다는 것이다. 아래는 그 예시 코드이다.
// 함수 정의
func condition(stmt: () -> Bool) {
if stmt() == true {
print("결과가 참입니다.")
}else {
print("결과가 거짓입니다.")
}
}
함수 condition(stmt:)는 참/거짓을 반환하는 클로저를 인자값으로 전달받고 그 결과값을 문장으로 출력해준다. 현재까지는 이 함수를 실행하려면 다음의 두 가지 방법을 사용했다.
// 1. 일반 구문
condition(stmt: {
4 > 2
})
// 2. 클로저 구문
condition {
4 > 2
}
작성된 2가지의 실행 방법에서 전달하고 싶은 것은 '4 > 2' 구문이다. 하지만 일반 구문이나 클로저 구문 둘 다 {} 형태로 감싸 클로저 형태로 만든 후 인자값으로 전달해야 한다. 인자값의 입력 타입이 반드시 클로저여야 하기 때문이다. 하지만 @autoclosure 속성을 붙이면 이같은 제약이 사라지고 구문만 인자값으로 전달해 줄 수 있다.
func condition(stmt: @autoclosure () -> Bool) {
if stmt() == true {
print("결과값이 참입니다.")
}else {
print("결과값이 거짓입니다.")
}
}
이제 @autoclosure 속성의 영향으로 더이상 일반 클로저를 인자값으로 사용할 수 없다. 또한 트레일링 클로저 구문도 @autoclosure 속성이 붙고 나면 더이상 사용할 수 없다.
// 실행 방법
condition(stmt: (4 > 2))
클로저가 아니라 그 안에 들어가는 내용만 인자값으로 넣어줄 뿐이다. 전달된 인자값은 컴파일러가 자동으로 클로저 형태로 감싸 처리해준다.
@autoclosure 속성과 관련하여 알아두어야 할 개념이 하나 있다. 바로 '지연된 실행'이다.
// 빈 배열 정의
var arrs = [String]()
func addVars(fn: @autoclosure () -> Void) {
// 배열 요소를 3개까지 추가하여 초기화
arrs = Array(repeating: "", count: 3)
// 인자값으로 전달된 클로저 실행
fn()
}
// 구문 1 : 아래 구문은 오류가 발생한다.
arr.insert("KR", at: 1)
// 구문 2 : 아래 구문은 오류가 발생하지 않는다.
addVars(fn: arrs.insert("KR", at: 1))
구문 1에서 빈 배열 arrs는 초기화만 되어 있고 내용은 모두 비어있는 상태로 addVars(fn:) 함수 내부에서는 이 배열의 사이즈를 3으로 확장하고 빈 값들로 초기화한다. 즉 addVars(fn:)함수가 실행되기 전까지 이 함수의 인덱스는 0까지 밖에 없다. 이 때문에 두번째 인덱스에 위치에 insert를 할 수 없는 것이다.
구문 2에서는 오류가 발생하지 않는다. 바로 지연된 실행 때문이다. 함수 내에 작성된 구문은 함수가 실행되기 전까지는 실행되지 않는다. @autoclosure 속성이 부여된 인자값은 보기엔 일반구문 형태이지만 컴파일러에 의해 클로저, 즉 함수로 감싸지기 때문에 위와 같이 작성해도 addVars(fn:) 함수 실행 전까지는 실행되지 않으며, 해당 구문이 실행될 때에는 이미 배열의 인덱스가 확장된 이후이므로 오류가 발생하지 않는다.
'swift' 카테고리의 다른 글
| 스위프트 프로퍼티 (0) | 2024.04.15 |
|---|---|
| 스위프트 구조체와 클래스 (0) | 2024.04.15 |
| 스위프트 일급 객체로서의 함수 (0) | 2024.04.12 |
| 스위프트 매개변수 (1) | 2024.04.09 |
| 스위프트 함수 (0) | 2024.04.09 |