스위프트는 객체지향 언어이자 동시에 함수형 언어이다. 함수형 언어를 학습하게 되면 반드시 일급 객체(First-Class Object)라는 용어를 접하게 된다. 이는 프로그램 언어 안에서 특정 종류의 객체가 일급의 지위를 가지는가에 대한 의미이다.
일급 함수의 특성
객체가 다음의 조건을 만족하는 경우 이 객체를 일급 객체로 간주한다.
- 객체가 런타임에도 생성이 가능해야 한다.
- 인자값으로 객체를 전달할 수 있어야 한다.
- 반환값으로 객체를 사용할 수 있어야 한다.
- 변수나 데이터 구조 안에 저장할 수 있어야 한다.
- 할당에 사용된 이름과 관계없이 고유한 구별이 가능해야 한다.
함수가 이런 조건을 만족하면 이를 일급 함수(First-Class Function)라고 하고, 그 언어를 함수형 언어로 분류한다. 즉, 함수형 언어에서는 함수가 일급 객체로 대우받는다는 뜻이다.
함수가 일급 객체로 대우받는다면 런타임에도 함수의 생성이 가능하고, 매개변수나 반환값으로 함수를 전달할 수 있으며, 함수를 변수나 데이터 구조 안에 저장할 수 있을 뿐만 아니라 함수의 이름과 관계없이 고유한 구별이 가능하다. 이것들이 일급 객체가 되기 위한 조건이기 때문이다.
일급 함수의 특성 1 - 변수나 상수에 함수를 대입할 수 있음
변수나 상수에 함수를 대입한다는 것은 함수 자체르 변수에 집어넣는다는 뜻이다. 아래는 그 예제이다.
// 정수를 입력받는 함수
func foo(base: Int) -> String {
return "결과값은 \(base + 1)입니다."
}
let fn1 = foo(base: 5)
// 결과값은 6입니다.
이 예시는 단지 함수의 결과값을 변수에 담은 것이지, 일급 함수에서 말하는 변수나 상수를 함수에 대입한다는 예시는 아니다.
let fn2 = foo //fn2 상수에 foo 함수가 할당
fn2(5) // 결과값은 6입니다.
상수 fn2에 foo 함수를 대입하고 있다. 함수 자체가 대입되었으므로 이제 fn2는 foo와 이름만 다를 뿐 같은 기능을 하는 함수가 된다.
변수나 상수에 함수를 대입할 때에는 함수가 실행되는 것이 아니라 함수라는 객체 자체만 대입된다. 아래는 다음 예제이다.
func foo(base: Int) -> String {
print("합수 foo가 실행됩니다.")
return "결과값은 \(base + 1)입니다."
}
앞서 작성한 함수 foo에서 내부에 출력 구문을 추가한 것이다. 우선 이 함수의 결과값을 상수에 할당할 것이다.
let fn3 = foo(base: 5)
// 실행 결과
// 함수 foo가 실행됩니다.
이처럼 함수의 결과값을 대입할 때는 함수가 실행된다. 하지만 함수 자체를 대입하는 구문은 다르다. 아래 코드는 함수 자체를 대입하는 코드이다.
let fn4 = foo
// 출력 결과 없음
fn4(7)
// 실행 결과
// 함수 foo가 실행됩니다.
상수 fn4에 foo함수를 대입하는 과정에서는 아무런 값도 출력되지 않는다. foo 함수가 실행되지 않았다는 뜻이다. fn4에 인자값 7을 넣어 함수를 실행하면 그때서야 메세지가 출력된다. foo 함수가 실행되었다는 것을 알 수 있다.
함수를 대입하기 위해 알아두어야 할 것이 있다. 바로 타입(Types)이다. 변수에 함수를 대입하면 그 변수는 지금까지 배운 것들과는 다른 타입이 되는데 이 타입을 함수 타입 이라고 한다.
함수 타입은 함수의 형태를 축약한 형태로 사용하는데, 이 때 함수의 이름이나 실행 내용 등은 함수 타입에서는 아무런 의미가 없으므로 생략할 수 있다. 함수 타입에서 필요한 것은 단지 어떤 값을 입력받는지와 어떤 값을 반환하는지 뿐이다.
(인자 타입1, 인자 타입2, ...) -> 반환 타입
아래는 실제 함수 코드이다.
func boo(age: Int) -> String {
return "\(age)"
}
이를 함수 타입으로 바꿔주면 다음과 같다.
(Int) -> String
이 함수를 할당한다면 이 상수의 타입 어노테이션을 포함한 할당 구문은 다음과 같다.
1. let fn: (Int) -> String = boo
2. let fn: (Int) -> String = boo(age:)
boo는 함수의 이름, boo(age:)는 함수의 식별자이다. 함수의 대입 구문을 작성할 때에는 함수의 이름이나 함수의 식별자 어느 것을 사용해도 된다. 그런데 1번의 경우 다음과 같은 경우에 문제가 발생할 수 있다.
func boo(age: Int) -> String {
return "\(age)"
}
func boo(age: Int, name: String) -> String {
return"\(name)의 나이는 \(age)입니다."
}
let t = boo // <- 불가능
이런 경우 t에 대입되는 함수는 어느 것을 가리키는지 판단할 수 없으므로 오류가 발생한다. 이를 해결하려면 두 가지중 하나로 바꾸어야 한다.
// 해결 방법 1 : 타입 어노테이션을 통해 입력받을 함수의 타입을 지정
let t1: (Int, String) -> String = boo
// 해결 방법 2 : 함수의 식별값을 통해 입력받을 정확할 함수를 지정
let t2 = boo(age:name:)
다음과 같이 타입 어노테이션을 적절히 사용하면 같은 함수의 이름을 사용하여 대입하더라도 서로 다른 결과를 가져오기도 한다.
let fn01: (Int) -> String = boo // boo(age:)
let fn02: (Int, String) -> String = boo // boo(age:name:)
두 케이스 모두 boo라는 함수를 할당받고 있다. 하지만 타입 어노테이션의 차이로 인해 각각 다른 함수가 대입된다. 이처럼 동일한 함수 이름을 사용하더라도 타입 어노테이션에 의해 대입되는 함수가 달라지기도 하므로 주의해야 한다.
타입 어노테이션과 함수 이름의 조합으로 대입 구문을 구성하면 안되는 경우도 있다. 동일한 함수 타입을 사용하지만 매개변수명이 서로 다른 함수의 경우가 이에 해당된다.
func boo(age: Int, name: String) -> String {
return "\(name)의 나이는 \(age)입니다."
}
func boo(height: Int, nick: String) -> String {
return "\(nick)의 키는 \(height)입니다."
}
let fn03: (Int, String) -> String = boo
let fn04: (Int, String) -> String = boo
인자값과 반환갑에 따른 함수 타입이 (Int, String) -> String 으로 동일하므로 타입 어노테이션만으로 함수를 특정하기 어렵다. 이 같은 부정확성에 따라 컴파일러는 오류를 발생시킨다. 오류를 피하기 위해, 함수의 이름이 아니라 식별자를 사용하여 정확하게 구분해야 한다.
let fn03: (Int, String) -> String = boo(age:name:)
let fn04: (Int, String) -> String = boo(height:nick:)
다시 함수타입으로 돌아가 몇 가지 특별한 형태의 함수 타입을 볼 것이다.
func boo(age: Int, name: String) -> (String, Int) {
return (name, age)
}
// 위 함수의 타입
// (Int, String) -> (String, Int)
func boo() -> String {
return "empty values"
}
// 위 함수의 타입
// () -> String
func boo(base: Int) {
print("param = \(base)")
}
// 위 함수의 타입
// (Int) -> ()
func boo() {
print("empty values")
}
// 위 함수의 타입
// () -> ()
함수 타입을 표시할 때 반환값이 없는 경우 빈 괄호 대신 Void를 사용하여 값이 없음을 표현하기도 한다. Void는 빈 튜플 값을 나타내는 키워드이다.
public typealias Void = ()
Void 를 적용해보면 함수 타입을 아래와 같이 표현 가능하다.
(Int) -> () ==> (Int) -> Void
() -> () ==> () -> Void
Void 키워드는 본래 빈 인자값의 표현에도 사용할 수 있었으나 4.0 버전부터는 반환 타입에만 사용할 수 있도록 제한되었다. 따라서 위의 두 번째 예제 인자 타입 ()는 그대로 ()로 사용된다.
일급함수의 특성 2 - 함수의 반환 타입으로 함수를 사용할 수 있음
일급 함수의 두 번째 특성은 함수의 반환 타입으로 함수를 사용할 수 있다는 특성이다. 아래는 다음 예시이다.
func desc() -> String {
return "this is desc()"
}
func pass() -> () -> String {
return desc
}
let p = pass()
p() // this is desc()
복잡해 보일 수 있지만 잘 따라가 보면 코드를 이해할 수 있다. pass 함수를 실행한 결과값은 desc 함수이고 결국 p라는 상수에는 desc 함수 자체가 담기게 된다. 즉 p()는 p 함수를 실행한 것이므로 desc()와 동일하여 "this is desc()"가 출력된다.
아래는 조금 더 복잡한 예제이다.
func plus(a: Int, b: Int) -> Int {
return a + b
}
func minus(a: Int, b: Int) -> Int {
return a - b
}
func times(a: Int, b: Int) -> Int {
return a * b
}
func divide(a: Int, b: Int) -> Int {
guard b != 0 else {
return 0
}
return a / b
}
func calc(_ operand: String) -> (Int, Int) -> Int {
switch operand {
case "+":
return plus
case "-":
return minus
case "*":
return times
case "/":
return divide
default:
return plus
}
}
위 4개의 함수들은 사칙연산을 구현한 함수들이다. 그런데 마지막으로 작성된 calc는 조금 다르다. 이 함수는 사칙 연산의 연산자를 문자열 형식으로 입력 받는다. 반환하는 함수의 타입 표현식은 (Int, Int) -> Int 이다. 아래는 함수를 실행한 결과이다.
let c = calc("+")
c(3, 4) // plus(3, 4) -> 7
let c2 = calc("-")
c2(3, 4) // minus(3, 4) -> -1
let c3 = calc("*")
c3(3, 4) // times(3, 4) -> 12
let c4 = calc("/")
c4(3, 4) // divied(3, 4) -> 0
이처럼 함수의 실행 결과로 다른 함수를 반환할 수 있는 것이 일급 함수의 특성이다.
일급함수의 특성 3 - 함수의 인자값으로 함수를 사용할 수 있음
일급 함수는 반환값으로 함수를 사용할 수 있을 뿐만 아니라 다른 함수의 인자값으로 함수를 전달할 수도 있는 특성이 있다. 다음은 함수를 인자값으로 전달하는 예제이다.
func incr(param: Int) -> Int {
return param + 1
}
func broker(base: Int, function fn: (Int) -> Int) -> Int {
return fn(base)
}
broker(base: 3, function: incr) // 4
incr(param:)은 정수값을 입력받아 +1 처리한 후 값을 반환하는 함수이다. broker(base:function:)은 인자로 받은 함수를 실행하는 함수이다. function이라는 파라미터는 정수를 파라미터로 받아 정수를 리턴하는 함수이다. broker(base:function) 함수의 정의 구문만으로는 어떤 연산이 실행될지 짐작하기 어렵다. 실질적인 연산은 인자로 받는 함수에 달려있기 때문이다. 이런 식으로 중개 역할을 하는 함수를 브로커(Broker)라고 한다.
이번에는 콜백 함수를 사용하는 예시이다.
func successThrough() {
print("연산 처리가 성공했습니다.")
}
func failThrough() {
print("처리 과정에서 오류가 발생했습니다.")
}
func divide(base: Int, success sCallBack: () -> Void, fail fCallBack: () -> Void) -> Int {
guard base != 0 else {
fCallBack() // 실패 함수 실행
return 0
}
defer {
sCallBack() // 성공 함수 실행
}
return 100 / base
}
divide(base: 30, success: successThrough, fail: failThrough)
// 실행 결과
// 연산 처리가 성공했습니다.
처음 보는 defer라는 키워드가 등장했다. defer 블록은 함수나 메소드에서 코드의 흐름과 상관 없이 가장 마지막에 실행되는 블록이다. 지연 블록이라고도 한다. 작성된 위치에 상관없이 항상 함수의 종료 직전에 실행되기 때문에, 종류 시점에 맞추어 처리해야 할 구문이 있다면 defer 블록안에 넣으면 된다. defer 블록은 다음과 같은 특성이 있다.
- defer 블록은 작성된 위치와 순서에 상관없이 함수가 종료되기 직전에 실행된다.
- defer 블록을 읽기 전에 함수의 실행이 종료될 경우 defer 블록은 실행되지 않는다.
- 하나의 함수나 메소드 내에서 defer 블록을 여러번 사용할 수 있다. 이때에는 가장 마지막에 작성된 defer 블록부터 역순으로 실행된다.
- defer 블록을 중첩해서 사용할 수 있다. 이때에는 바깥쪽 defer 블록부터 실행되며 가장 안쪽에 있는 defer 블록은 가장 마지막에 실행된다.
divide(base: 30, success: successThrough, fail: failThrough)
이 구문을 다시 살펴보면 인자값으로 사용하기 위해 새로운 성공/실패 함수를 작성해야 하는 것은 번거롭다. 이런 문제를 해결하고자 많은 함수형 언어에서는 익명 함수를 지원한다. 쉽게 생각해서 일회용 함수라고 생각하면 된다.
스위프트에서도 익명함수를 지원한다. 이를 클로저(Closure)라고 한다. 위 예제의 호출 부분을 익명함수로 이용해 작성해 볼것이다.
divide(base: 30,
success: {
() -> Void in
print("연산 처리가 성공했습니다.")
},
fail: {
() -> Void in
print("처리 과정에서 오류가 발생했습니다.")
}
)
함수의 중첩
스위프트에서는 함수를 중첩하여 사용할 수 있다. 이렇게 작성된 함수를 중첩 함수라고 한다. 함수 내에 작성된 함수는 내부 함수, 내부 함수를 포함하는 바깥쪽 함수는 외부 함수이다.
함수 내에 작성할 수 있는 내부 함수의 수에는 제한이 없다. 외부 함수 내에 여러 개의 내부 함수를 정의할 수도 있고, 외부 함수 내에 작성된 내부 함수에 또 다른 내부 함수를 작성할 수도 있다.
외부 함수가 종료되면 내부 함수도 종료되게 되는데 이를 내부 함수의 생명 주기(LifeCycle)라고 한다. 내부 함수는 일반적으로 외부 함수를 거치지 않으면 접근할 수 없다. 이 때문에 내부 함수는 외부의 코드로부터 차단되는 결과를 가져온다. 이를 함수의 은닉성 이라고 한다. 중첩된 함수를 구현하면 함수의 은닉성을 높일 수 있다. 아래는 그 예시 코드이다.
// 외부 함수
func outer(base: Int) -> String {
// 내부 함수
func inner(inc: Int) -> String {
return "\(inc)를 반환합니다."
}
let result = inner(inc: base + 1)
return result
}
outer(base: 3)
// 4를 반환합니다.
outer는 Int 타입의 값을 인자값으로 받아 문자열을 반환하는 함수이다. 이 함수 내부에는 inner라는 이름의 함수가 작성되어 있는데, 이 함수는 외부에서 참조할 수 없으며 오로지 outer 함수 내부에서만 참조할 수 있다. 직접 인자값을 전달할 수도 없다. 말하자면 inner 함수는 외부로부터 은닉되어 있다.
함수의 생명 주기는 참조 카운트와 관련되어 있다. 함수는 참조 카운트가 0에서 1이 되는 순간 생성하여 1이상인 동안유지되다가, 0이 되면 소멸하는 과정을 반복한다.
내부 함수를 참조할 수 있는 곳은 그 함수를 선언해준 외부 함수 이외에는 없다. 나머지 외부 범위로부터 내부 함수는 은닉되기 때문이다. 외부 함수가 실행되면서 내부 함수에 대한 참조가 발생하면 생성되고, 외부 함수가 종료되면서 내부 함수에 대한 참조도 종료되면 내부 함수는 소멸한다.
// 외부 함수
func outer(param: Int) -> (Int) -> String {
// 내부 함수
func inner(inc: Int) -> String {
return "\(inc)를 반환합니다."
}
return inner
}
let fn1 = outer(param: 3) // outer가 실행되고, 그 결과로 inner 가 대입된다.
let fn2 = fn1(30) // inner(inc: 30)과 동일하다.
여기서 주의 깊게 봐야할 점은 은닉성이 있는 내부 함수 inner를 외부 함수의 실행 결과로 반환하므로써 내부 함수를 외부에서도 접근할 수 있는 길이 열렸다는 점이다. 이제까지 내부에서 정의된 함수 inner는 오로지 외부 함수은 outer를 통해서만 접근할 수 있었다. 이로 인해 완벽한 은닉성이 제공 되었다. 하지만 내부 함수를 이렇게 반환하면 outer 함수의 실행 결과는 내부 함수 inner 그 자체가 된다. 이를 할당받은 상수fn1에는 내부 함수가 대입되므로 fn1을 사용하여 얼마든지 inner 를 호출할 수 있다.
inner 함수의 생명주기에도 주의할 필요가 있다. 본래 inner는 외부 함수인 outer가 실행 종료되면 소멸하도록 설계되어 있다. 즉, 원래대로는 다음 구문이 실행되면 inner는 소멸되어야 한다.
let fn1 = outer(param: 3) // outer()가 실행되고, 그 결과로 inner 가 대입된다.
그런데 inner 함수가 소멸하지 않고 fn1에 할당된 채로 생명을 유지하다가 (30)이라는 함수 호출 연산자 구문을 만나 실행되는 것을 확인할 수 있다. 즉 외부 함수에서 내부 함수를 반환하게 되면 외부 함수가 종료되더라도 내부 함수의 생명이 유지된다.
그러나 만약 내부 함수에 외부 함수의 지역 상수, 또는 지역 변수가 참조되면 어떻게 되는지 예제 코드를 살펴볼 것이다.
func basic(param: Int) -> (Int) -> Int {
let value = param + 20
func append(add: Int) -> Int {
return value + add
}
return append
}
// 1
let result = basic(param: 10)
// 2
result(10) // 40
1번에서 basic 함수가 실행되고 내부 함수인 append가 반환된다. basic함수는 실행이 끝나 종료되지만 반환된 내부 함수 append는 상수 result가 참조하고 있기 때문에 소멸하지 않고 계속 남아있다가 2번에서 실행된다.
궁금한점은 2번 코드가 실행될 때 value 상수는 존재하지 않아 오류가 발생할 것 같지만, 그렇지 않다. 이 현상의 원인은 클로저(Closure) 때문이다. 정확히는 append 함수가 클로저를 갖기 때문이다. 클로저를 설명하자면 다음과 같다.
- 클로저는 두 가지로 이루어진 객체이다. 하나는 내부 함수이며, 또 다른 하나는 내부 함수가 만들어진 주변 환경이다.
- 클로저는 외부 함수 내에서 내부 함수를 반환하고, 내부 함수가 외부 함수의 지역 변수나 상수를 참조할 때 만들어진다.
이를 요약하면 "클로저란 내부 함수와 내부 함수에 영향을 미치는 주변 환경(Context)을 모두 포함한 객체"이다.