스위프트는 객체지향 언어이자 동시에 함수형 언어이다. 함수형 언어를 학습하면 반드시 일급 객체(First-Class Object)라는 용어를 접하게 되는데 객체가 다음의 조건을 만족하는 경우 이 객체를 일급 객체로 간주한다.
- 객체가 런타임에도 생성이 가능해야 한다.
- 인자값으로 객체를 전달할 수 있어야한다.
- 반환값으로 객체를 사용할 수 있어야한다.
- 변수나 데이터 구조 안에 저장할 수 있어야한다.
- 할당에 사용된 이름과 관계없이 고유한 구별이 가능해야한다.
함수가 이런 조건을 만족하면 이를 일급 함수(First-Class Function)라고 하고 그 언어를 함수형 언어로 분류한다. 즉, 함수형 언어에서는 함수가 일급 객체로 대우받는다는 뜻이다. 지금부터 일급함수의 특성에 대해 하나씩 살펴보자.
✅ 일급함수의 특성 1. 변수나 상수에 함수를 대입할 수 있다
일급함수의 특성중 하나는 변수나 상수에 함수를 대입할 수 있다는 것이다. 변수나 상수에 함수를 대입한다느 뜻은 함수 자체를 변수에 집어넣는다는 뜻이다. 이렇게 함수가 대입된 변수나 상수는 함수처럼 실행할 수도 있고, 인자값을 입력받을 수도 있다.
// 정수를 입력받는 함수
func foo(base: Int) -> String {
return "결과값은 \(base + 1)입니다"
}
let fn1 = foo(base: 5)
// "결과값은 6입니다"
예제에 정의된 foo 함수는 정수를 입력받아 문자열로 반환한다. 마지막 행에선 이 함수에 5라는 인자값을 넣어 실행하고 그 결과를 상수 fn1에 할당하고 있다.이 구문은 단지 함수의 결과값을 fn1이라는 상수에 할당하는 단순한 대입 연산에 지나지 않는다.
일급 함수에서 말하는 '변수나 상수에 함수를 대입한다'라는 의미는 이것과 다르다. 함수의 결과값을 대입하는 것이 아니라 함수 자체를 대입하는 것이니 말이다. 일급 함수의 특성에 따라 우리는 굳이 함수의 이름이 아니더라도 함수가 할당된 변수라면 그 변수에 함수 호출 연산자 ()를 붙여서 함수를 호출할 수 있어야한다. 다음 예를 살펴보자.
let fn2 = foo // fn2 상수에 foo 함수가 할당됨
fn2(5) // "결과값은 6입니다"
상수 fn2에 foo 함수를 대입하고 있다. 함수 자체가 대입되었으므로 이제 fn2는 foo와 이름만 다를 뿐 같은 인자값, 같은 기능, 같은 반환값을 가지는 함수가된다. 따라서 fn2에 함수 호출연산자인 ()와 인자값 5를 넣어 호출할 수도 있고 그 결과값을 받을 수도 있다. fn2는 함수이니 말이다.
변수나 상수에 함수를 대입할 때는 함수가 실행되는 것이 아니라 함수라는 객체 자체만 대입된다. 다음 예제를 보자.
func foo(base: Int) -> String {
print("함수 foo가 실행됩니다")
return "결과값은 \(base + 1)입니다"
}
앞서 작성한 함수 foo에서 내부에 출력 구문을 추가하였다. 이 함수가 실행되면 "함수 foo가 실행됩니다"라는 구문이 출력되도록 말이다. 우선 이 함수의 결과값을 상수에 할당해 보면 다음과 같다.
let fn3 = foo(base: 5)
/* 실행결과
"함수 foo가 실행됩니다"
함수의 결과값을 fn3에 대입하는 과정에서 함수가 실행되었다. 하지만 함수 자체를 대입하는 구문은 다르다. 다음 함수 자체를 대입하는 구문을 살펴보자.
let fn4 = foo
// 출력결과 없음
fn4(7)
/* 실행결과
"함수 foo가 실행됩니다"
상수 fn4에 foo 함수를 대입하는 과정에선 아무런 값도 출력되지 않는다. foo 함수가 실행되지 않았다는 뜻이다. 함수를 할당받은 상수 fn4가 인자값 7을 넣어 함수를 실행하면 그때서야 메시지가 출력된다.
이처럼 단순히 함수를 변수나 상수에 대입하는 과정에서는 함수가 실행되지 않는다. 왜냐하면 함수 객체 자체만 전달되기 때문이다. 함수가 대입된 변수나 상수를 함수처럼 호출하면 그 때야 비로소 함수가 실행된다. 이것이 함수 결과값을 대입하는 것과 함수 자체를 대입하는 것의 차이점이다.
함수를 대입하기 위해 알아야할 것이 하나 더 있다. 바로 타입(Types)이다. 변수에 함수를 대입하면 그 변수는 일반적인 문자열, 정수, 배열 또는 딕셔너리와는 전혀 다른 타입이 됩니다. 이 타입을 함수 타입(Function Types)라고 한다.
함수 타입은 일반적으로 함수의 형태를 축약한 형태로 사용하는데, 이 때 함수의 이름이나 실행 내용 등은 함수 타입에선 아무런 의미가 없으므로 생략할 수 있다. 함수 타입에서 필요한 것은 단지 어떤 값을 입력받는지와 어떤값을 반환하는지 뿐이다. 이를 함수 타입에서는 다음과 같이 나타낸다.
(인자 타입 1, 인자 타입 2 ...) -> 반환 타입
단, 아무값도 반환하지 않는 함수일 경우, 정의 구문에서는 반환 타입을 생략할 수 있지만 함수 타입에서는 'Void'라고 명시해줘야 한다.
실제 함수를 보면서 함수 타입에 대한 내용을 익혀보자.
func boo(age: Int) -> String {
return "\(age)"
}
정수 인수를 받고 문자열 타입을 반환하는 boo(age:) 함수이다. 이 함수를 타입 형태로 표현하면 다음과 같다.
(Int) -> String
만약 이 함수를 상수에 할당한다면 이 상수의 타입 어노테이션을 포함한 할당 구문은 다음과 같다.
let fn: (Int) -> String = boo
이번에는 두 개의 인자값을 받는 함수의 타입을 작성해보자.
func boo(age: Int, name: String) -> String {
return "\(name)의 나이는 \(age) 입니다"
}
// 함수 타입
(Int, String) -> String
// 상수에 할당
let s: (Int, String) -> String = boo
// 상수에 할당(정확한 함수 식별자 대입)
let s: (Int, String) -> String = boo(age:name:)
boo와 boo(age:name:) 두 가지 모두 함수를 대입하는 구문에 사용해도 아무런 문제가 없다. 물론 정확한 표현을 선택하라면 boo(age:name:)을 선택해야 한다. 따지자면 boo는 함수의 이름, boo(age:name:)는 함수의 식별자로 나누어 생각하는 것이 좋다. 물론 함수의 대입 구문을 작성할 때엔 둘 중 어느것을 사용해도 상관이 없다.
정확하게 boo는 매개변수를 제외한 함수의 이름이 boo인 모든 함수를 대변하고, boo(age:name:)은 함수의 이름이 boo이면서 매개변수가 각각 age, name인 함수를 기리킨다. 이로 인해 문제가 발생할 수 있는데 다음 예제를 살펴보자.
func boo(age: Int) -> String {
return "\(age)"
}
func boo(age: Int, name: String) -> String {
return "\(name)의 나이는 \(age)세 입니다"
}
let t = boo // ( X )
두 함수 모두 함수이름은 boo이지만 뒤의 매개변수 차이 때문에 서로 다른 식별값을 가진다. 따라서 boo로 대입하려면 둘 중 어느 함수를 가리키는 것인지 정확하게 판단할 수 없으므로 오류가 발생하게 된다. 이 오류를 해결하려면 다음 두 가지 중 하나의 형태로 바꾸어 주어야 한다.
// 해결법 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:)
이처럼 동일한 함수 이름을 사용하여 대입하더라도 타입 어노테이션에 의해 대입되는 함수가 달라지기도 하므로 주의해야한다.
타입 어노테이션과 함수 이름의 조합으로 대입 구문을 구성하면 안 되는 경우도 있다. 동일한 함수 타입을 사용하지만 매개변수명이 서로 다른 함수의 경우가 이에 해당한다.
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
정의된 두 개의 함수는 각각 boo(age:name:)와 boo(height:nick:)으로 서로 다른함수이다. 하지만 함수의 이름이 boo로 같고 함수의 타입또한 (Int, String) -> String으로 동일하기 떄문에 타입 어노테이션만으로 함수를 특정하기가 어렵다. 이 같은 부정확성에 따라 컴파일러는 오류를 발생하게 된다. 이 같은 경우는 함수의 이름이 아니라 함수의 식별자를 사용해서 다음과 같이 정확하게 구분해줘야 한다.
let fn03: (Int, String) -> String = boo(age: name:)
let fn04: (Int, String) -> String = boo(height:nick:)
// 간결하게
let fn03 = boo(age:name:)
let fn04 = boo(height:nick:)
다시 함수 타입으로 돌아가보자. 몇 가지 특별한 형태의 함수 타입에 대해 살펴보자.
// 튜플을 반환값으로하는 함수
func foo(age: Int, name: String) -> (String, Int) {
return (name, age)
}
// 함수 타입
(Int, String) -> (String, Int)
인자값이 없거나 반환값이 없는 함수의 타입은 다음과 같이 빈 괄호를 사용해 표현한다.
// 인자값이 없는 경우
func foo() -> String {
return "Empty Values"
}
// 인자값이 없는 경우의 함수 타입
() -> String
Void -> String
// 반환값이 없는 경우
func boo (base: Int) {
print("param = \(base)")
}
// 반환값이 없는 경우의 함수 타입
(Int) -> ()
(Int) -> Void
// 인자값, 반환값 모두 없는 경우
func too() {
print("empty values")
}
// 인자값, 반환값 모두 없는 경우의 함수 타입
() -> ()
Void -> Void
이와 같이 함수 타입을 표시할 때 반환값이 없는 경우에는 빈 괄호 대신 'Void'를 사용하여 명시적으로 "값이 없음"을 표시하기도 한다. Void는 빈 튜플을 나타내는 값으로, 타입 앨리아스로 정의된 단어이다. 클래스나 구조체 등의 객체가 아닌 키워드임에 주의하자.
✅ 일급함수의 특성 2. 함수의 반환 타입으로 함수를 사용할 수 있다
일급 객체로 대우받는 함수는 실행 결과로 정수, 실수, 문자열 등의 기본 자료형이나 클래스, 구조체 등의 객체를 반환할 수 있을 뿐만 아니라 함수 자체를 반환할 수도 있다. 다음 예제를 살펴보자.
func desc() -> String {
return "this is desc()"
}
func pass() -> () -> String {
return desc
}
let p = pass()
p() // "this is desc()"
pass 함수를 살펴보면 그냥 desc 함수 자체를 반환하는 구문이다. 여기서 pass 함수의 반환타입은 함수 타입인 ( ) -> String으로 정의된다. 이는 pass 함수가 desc 함수를 반환하기 떄문으로, ( ) -> String은 desc 함수의 타입 표현에 해당한다. desc 함수는 인자값 없이 문자열만 반환하고 있기 때문에 이를 함수 타입으로 표현하면 ( ) -> String 형식이 된다. 이 함수 타입의 표현이 pass 함수의 반환 타입으로 사용되고 있는 것이다.
상수 p는 pass 함수의 실행 결과값을 할당받고 있는데 pass 함수가 desc함수를 반환하니 사실상 상수 p에는 desc 함수가 할당된다고 할 수 있다. 그래서 p( )는 "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
}
}
반환하는 함수의 인자값과 반환값은 모두 일치하며 이들 함수의 타입 표현식은 (Int, Int) -> Int이다. 함수 calc에 인자값으로 연산자를 입력하면 그에 해당하는 함수를 반환받을 수 있다.
let c = calc("+") // operand 매개변수는 생략 가능 '_'가 외부매개변수명으로 있기때문에
c(3,4) // plus(3,4) = 7
"+"문자열을 인자값으로 하여 함수를 실행한 결과, switch 구문의 패턴 비교에 의해 plus 함수가 반환된다. 이 함수가 상수 c에 할당되었으므로 이를 함수 호출 연산자로 실행하면 plus 함수가 실행된다. 그래서 결과값은 7이 나오는 것이다. 다음과 같이 하나의 구문으로 통합해서 작성하는 것도 가능하다.
calc("+")(3,4)
하지만 이런식으로 작성할 경우 가독성이 매우 떨어지기 때문에 웬만하면 단계적으로 표현하는 것이 좋다.
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) // divide(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
실질적인 연산은 인자값으로 받는 함수에 달려 있다. 이런식으로 중개 역할을 하는 함수를 브로커(Broker)라고 한다. 마지막 행에서 broker(base:function:) 함수는 정수 3과 incr(param:) 함수를 인자값으로 하여 호출된다. 내부적으로 실행될 함수가 incr(param:)인 것이 파악된 지금에서야 비로소 broker(base:function:)가 처리할 내용을 짐작할 수 있게 되는 것이다.
이처럼 함수를 인자로 사용하면 실행 전까지 어떤 구문이 수행될지 컴파일러가 미리 알 수 없으므로 컴파일 시점에서 디버깅할 수 없는 단점이 있다. 하지만 잘 활용하면 동적으로 정의되는 좋은 함수를 만들수 있기 때문에 매직 코드(Magic Code)라고 부르는 마법 같은 코드를 작성할 때 자주 응용된다.
이번에는 콜백 함수를 사용하는 예를 살펴보자.
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)
/* 실행결과
연산 처리가 성공했습니다.
divide(base:success:fail:) 함수는 내부 연산이 성공하거나 실패했을 때 값을 반환하는 것 이외에는 아무것도 하지 않는다. 단순히 인자값으로 입력받은 함수를 실행할 뿐이다.
이런 식의 구문을 작성하면 함수가 성공, 또는 실패했을 때의 처리 과정을 외부에서 제어할 수 있다. 함수의 내부 코드를 수정하지 않고도 외부에서 함수 내부의 실행 과정에 간섭할 수 있다는 뜻이다.
만약 base가 0이라면 조건 실패(0으로는 나눌 수 없기 때문)로 인해 else 구문의 내용이 실행되는데 fCallBack은 함수 처럼 보이나 실제로는 매개변수이다. 여기에 함수가 대입되었을 뿐이다. else 구문은 외부에서 전달된 실패 처리 함수를 fCallBack 매개변수를 통해 실행한 다음 0을 반환하면서 함수를 종료한다. 이렇기 때문에 실제로 함수가 실행되기 전에는 실패 처리 내용을 알 수 없는 것이다. 오로지 'fCallBack 매개변수에 대입된 함수를 실행한다'라는 구문만 있을 뿐이다.
마찬가지로, 입력된 base의 값이 0이 아닐 경우 나누기 연산이 실행된다. 연산이 성공했을 때엔 sCallBack 매개변수에 대입된 함수가 실행되는데, 이 때 값을 반환하는 return 구문과 성공 함수를 실행하는 과정 사이에 발생할 수 있는 미묘한 타이밍 차이를 해결하기 위해 defer 구문이 사용된다.
defer 블록은 함수나 메소드에서 코드의 흐름과 상관없이 가장 마지막에 실행되는 블록이다. 지연 블록이라고 부르기도 하는데 이 블록에 작성된 구문은 작성된 위치에 상관없이 항상 함수의 종료 직전에 실행되기 때문이다. 실제로 이 블록은 함수에서 사용된 각종 리소스의 처리나 해제, 연결 종료 등의 구문을 처리하는 용도로 유용하게 사용된다.
계속해서 마지막 실행구문을 살펴보자. divide(base:success:fail:) 함수를 호출하는 부분이다. 첫 번째 인자값으로 30을, 두 번째 세 번째 인자값으로 각각 successThrough 함수와 failThrough 함수를 입력해주는데, 함수의 연산이 성공하면 successThrough 함수를, 실패하면 FailThrough 함수를 실행해달라는 의미이다. 그 결과로 successThrough 함수에서 작성된 구문이 실행되었다.
함수를 인자로 넘기는 가장 큰 목적은 함수 내부의 코드에 손대지 않고 외부에서 실행흐름을 추가하기 위함이다. 원래라면 실행 구문들이 들어가야 하겠지만, 구문 자체를 인자로 넣을수는 없으므로 구문을 집약한 함수가 인자값으로 사용되는 것이다. 이 같은 점은 함수를 그만큼 재활용할 수 있다는 장점이 된다.
그런데 인자값으로 사용하기 위해 매번 새로운 성공 / 실패 함수를 작성하는 것도 번거로운 일이다. 이런 문제를 해결하고자 많은 함수형 언어에서는 익명함수를 지원한다. 익명함수는 쉽게 생각해 일회용 함수라고 생각하면된다. 함수의 형태를 가지지만 이름이 부여되지 않고 여러 가지 간편한 작성 형식을 따른다는 특성이 있다.
스위프트에서도 익명함수를 지원하는데 이를 클로저(Closure)라고 부른다. 클로저에 대해선 추후 포스팅에서 제대로 다루겠다.
출처 : 꼼꼼한 재은씨의 Swift 문법편
'Swift > 문법' 카테고리의 다른 글
스위프트(Swift) - 함수(Function) Ⅷ. 클로저(Closure) (0) | 2022.05.24 |
---|---|
스위프트(Swift) - 함수(Function) Ⅶ. 함수의 중첩 (0) | 2022.05.23 |
스위프트(Swift) - 함수(Function) Ⅴ. 변수의 생존 범위와 생명 주기 (0) | 2022.05.18 |
스위프트(Swift) - 함수(Function) Ⅳ. InOut 매개변수 (0) | 2022.05.17 |
스위프트(Swift) - 함수(Function) Ⅲ. 가변인자, 기본값, 수정 (0) | 2022.05.16 |